569 lines
19 KiB
HTML
569 lines
19 KiB
HTML
<!DOCTYPE html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="UTF-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
<title>Code Space Browser</title>
|
|
<style>
|
|
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
body { overflow: hidden; background: #0a0a0f; font-family: 'Courier New', monospace; }
|
|
canvas { display: block; }
|
|
#info {
|
|
position: fixed; top: 16px; left: 16px; color: #8af; font-size: 13px;
|
|
background: rgba(0,0,0,0.7); padding: 10px 14px; border-radius: 8px;
|
|
border: 1px solid rgba(100,160,255,0.2); z-index: 10; line-height: 1.6;
|
|
}
|
|
#info kbd { background: rgba(100,160,255,0.15); padding: 2px 6px; border-radius: 3px; font-size: 11px; }
|
|
#tooltip {
|
|
position: fixed; display: none; color: #cef; font-size: 12px;
|
|
background: rgba(5,5,20,0.9); padding: 8px 12px; border-radius: 6px;
|
|
border: 1px solid rgba(100,180,255,0.3); pointer-events: none; z-index: 20;
|
|
max-width: 300px; white-space: pre-wrap;
|
|
}
|
|
#vrButton {
|
|
position: fixed; bottom: 20px; right: 20px; z-index: 10;
|
|
background: linear-gradient(135deg, #2a6aff, #0af); color: #fff;
|
|
border: none; padding: 12px 24px; border-radius: 8px; font-size: 14px;
|
|
cursor: pointer; font-weight: bold; display: none;
|
|
}
|
|
#vrButton:hover { filter: brightness(1.2); }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<div id="info">
|
|
<strong style="color:#5bf">⬡ Code Space Browser</strong><br>
|
|
<kbd>Scroll</kbd> zoom · <kbd>Drag</kbd> rotate · <kbd>Right-drag</kbd> pan<br>
|
|
<kbd>Hover</kbd> file info · <kbd>Click</kbd> inspect
|
|
</div>
|
|
<div id="tooltip"></div>
|
|
<button id="vrButton">Enter VR</button>
|
|
|
|
<script type="importmap">
|
|
{
|
|
"imports": {
|
|
"three": "https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.module.js"
|
|
}
|
|
}
|
|
</script>
|
|
<script type="module">
|
|
import * as THREE from 'three';
|
|
|
|
// ── Sample Codebase ──
|
|
const codebase = {
|
|
name: "my-project", children: [
|
|
{ name: "src", children: [
|
|
{ name: "main.ts", lines: 245 },
|
|
{ name: "app.ts", lines: 380 },
|
|
{ name: "config.ts", lines: 65 },
|
|
{ name: "components", children: [
|
|
{ name: "Header.tsx", lines: 120 },
|
|
{ name: "Sidebar.tsx", lines: 210 },
|
|
{ name: "Editor.tsx", lines: 580 },
|
|
{ name: "FileTree.tsx", lines: 175 },
|
|
{ name: "StatusBar.tsx", lines: 88 },
|
|
{ name: "Modal.tsx", lines: 145 },
|
|
]},
|
|
{ name: "utils", children: [
|
|
{ name: "parser.ts", lines: 420 },
|
|
{ name: "formatter.ts", lines: 190 },
|
|
{ name: "helpers.ts", lines: 135 },
|
|
{ name: "logger.ts", lines: 78 },
|
|
]},
|
|
{ name: "services", children: [
|
|
{ name: "api.ts", lines: 310 },
|
|
{ name: "auth.ts", lines: 225 },
|
|
{ name: "storage.ts", lines: 160 },
|
|
{ name: "websocket.ts", lines: 290 },
|
|
]},
|
|
{ name: "types", children: [
|
|
{ name: "index.d.ts", lines: 340 },
|
|
{ name: "api.d.ts", lines: 180 },
|
|
{ name: "components.d.ts", lines: 95 },
|
|
]},
|
|
]},
|
|
{ name: "tests", children: [
|
|
{ name: "unit", children: [
|
|
{ name: "parser.test.ts", lines: 520 },
|
|
{ name: "formatter.test.ts", lines: 280 },
|
|
{ name: "auth.test.ts", lines: 190 },
|
|
]},
|
|
{ name: "e2e", children: [
|
|
{ name: "app.e2e.ts", lines: 380 },
|
|
{ name: "editor.e2e.ts", lines: 450 },
|
|
]},
|
|
]},
|
|
{ name: "scripts", children: [
|
|
{ name: "build.sh", lines: 45 },
|
|
{ name: "deploy.sh", lines: 78 },
|
|
{ name: "seed.ts", lines: 120 },
|
|
]},
|
|
{ name: "package.json", lines: 42 },
|
|
{ name: "tsconfig.json", lines: 28 },
|
|
{ name: "README.md", lines: 156 },
|
|
{ name: "Dockerfile", lines: 35 },
|
|
]
|
|
};
|
|
|
|
// ── Fake code generator ──
|
|
const keywords = ['const','let','function','return','if','else','for','import','export','async','await','class','interface','type','from','new','this','try','catch','throw'];
|
|
const types = ['string','number','boolean','void','Promise','Array','Map','Record','any'];
|
|
const vars = ['data','result','config','items','ctx','req','res','err','buf','node','key','val','opts','state','props','ref','cb','fn','idx','len'];
|
|
|
|
function generateFakeCode(lines) {
|
|
const out = [];
|
|
let indent = 0;
|
|
for (let i = 0; i < lines; i++) {
|
|
const r = Math.random();
|
|
if (r < 0.05 && indent > 0) { indent--; out.push(' '.repeat(indent) + '}'); continue; }
|
|
if (r < 0.1) { out.push(''); continue; }
|
|
if (r < 0.15) { out.push(' '.repeat(indent) + '// ' + vars[Math.random()*vars.length|0] + ' processing'); continue; }
|
|
const pre = ' '.repeat(indent);
|
|
if (r < 0.25) {
|
|
const kw = Math.random() < 0.5 ? 'function' : 'const';
|
|
const v = vars[Math.random()*vars.length|0];
|
|
const t = types[Math.random()*types.length|0];
|
|
out.push(pre + `${kw} ${v}: ${t} = {`);
|
|
indent++; continue;
|
|
}
|
|
if (r < 0.35) {
|
|
out.push(pre + `if (${vars[Math.random()*vars.length|0]}) {`);
|
|
indent++; continue;
|
|
}
|
|
const v1 = vars[Math.random()*vars.length|0];
|
|
const v2 = vars[Math.random()*vars.length|0];
|
|
out.push(pre + `${keywords[Math.random()*keywords.length|0]} ${v1} = ${v2};`);
|
|
}
|
|
while (indent > 0) { indent--; out.push(' '.repeat(indent) + '}'); }
|
|
return out;
|
|
}
|
|
|
|
// ── Squarified Treemap ──
|
|
function squarify(items, rect) {
|
|
if (!items.length) return [];
|
|
const sorted = [...items].sort((a, b) => b.size - a.size);
|
|
const totalSize = sorted.reduce((s, i) => s + i.size, 0);
|
|
const results = [];
|
|
layoutStrip(sorted, rect, totalSize, results);
|
|
return results;
|
|
}
|
|
|
|
function layoutStrip(items, rect, totalArea, results) {
|
|
if (items.length === 0) return;
|
|
if (items.length === 1) {
|
|
results.push({ ...items[0], rect: { ...rect } });
|
|
return;
|
|
}
|
|
|
|
const { x, y, w, h } = rect;
|
|
const vertical = h <= w;
|
|
const side = vertical ? h : w;
|
|
|
|
let strip = [items[0]];
|
|
let stripArea = items[0].size;
|
|
let bestRatio = worstRatio(strip, stripArea, side, totalArea);
|
|
|
|
let i = 1;
|
|
for (; i < items.length; i++) {
|
|
const newStrip = [...strip, items[i]];
|
|
const newStripArea = stripArea + items[i].size;
|
|
const newRatio = worstRatio(newStrip, newStripArea, side, totalArea);
|
|
if (newRatio > bestRatio) break;
|
|
strip = newStrip;
|
|
stripArea = newStripArea;
|
|
bestRatio = newRatio;
|
|
}
|
|
|
|
const stripFraction = stripArea / totalArea;
|
|
let offset = 0;
|
|
|
|
for (const item of strip) {
|
|
const frac = item.size / stripArea;
|
|
let r;
|
|
if (vertical) {
|
|
const sw = w * stripFraction;
|
|
r = { x: x, y: y + offset * h, w: sw, h: frac * h };
|
|
offset += frac;
|
|
} else {
|
|
const sh = h * stripFraction;
|
|
r = { x: x + offset * w, y: y, w: frac * w, h: sh };
|
|
offset += frac;
|
|
}
|
|
results.push({ ...item, rect: r });
|
|
}
|
|
|
|
const remaining = items.slice(i);
|
|
if (remaining.length > 0) {
|
|
const remArea = totalArea - stripArea;
|
|
let newRect;
|
|
if (vertical) {
|
|
newRect = { x: x + w * stripFraction, y, w: w * (1 - stripFraction), h };
|
|
} else {
|
|
newRect = { x, y: y + h * stripFraction, w, h: h * (1 - stripFraction) };
|
|
}
|
|
layoutStrip(remaining, newRect, remArea, results);
|
|
}
|
|
}
|
|
|
|
function worstRatio(strip, stripArea, side, totalArea) {
|
|
const stripLen = (stripArea / totalArea) * side;
|
|
if (stripLen === 0) return Infinity;
|
|
let worst = 0;
|
|
for (const item of strip) {
|
|
const itemLen = (item.size / stripArea) * side; // incorrect, should use other dim
|
|
const area = (item.size / totalArea) * side * side; // approx
|
|
// simplified: use fraction-based
|
|
const frac = item.size / stripArea;
|
|
const w = stripLen;
|
|
const h = frac * (side);
|
|
const ratio = Math.max(w / h, h / w);
|
|
worst = Math.max(worst, ratio);
|
|
}
|
|
return worst;
|
|
}
|
|
|
|
// ── Color palette ──
|
|
const dirColors = [0x1a3a5c, 0x1a4c3c, 0x3c1a4c, 0x4c3c1a, 0x1a2c4c, 0x2c1a1a, 0x1a4c4c, 0x3c2a1a];
|
|
const fileColors = {
|
|
ts: 0x2255aa, tsx: 0x2266bb, js: 0xccaa22, json: 0x44aa44,
|
|
md: 0x8866cc, sh: 0x55aa77, d: 0x336699, Dockerfile: 0x2299cc
|
|
};
|
|
|
|
function getFileColor(name) {
|
|
if (name.includes('.d.ts') || name.includes('.d.')) return fileColors.d;
|
|
const ext = name.split('.').pop();
|
|
return fileColors[ext] || 0x3377aa;
|
|
}
|
|
|
|
// ── Scene setup ──
|
|
const scene = new THREE.Scene();
|
|
scene.background = new THREE.Color(0x06060f);
|
|
scene.fog = new THREE.FogExp2(0x06060f, 0.015);
|
|
|
|
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000);
|
|
camera.position.set(0, 18, 25);
|
|
camera.lookAt(0, 3, 0);
|
|
|
|
const renderer = new THREE.WebGLRenderer({ antialias: true });
|
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
|
|
renderer.xr.enabled = true;
|
|
document.body.appendChild(renderer.domElement);
|
|
|
|
// Lights
|
|
scene.add(new THREE.AmbientLight(0x334466, 0.6));
|
|
const dirLight = new THREE.DirectionalLight(0x88aaff, 0.8);
|
|
dirLight.position.set(10, 20, 15);
|
|
scene.add(dirLight);
|
|
const pointLight = new THREE.PointLight(0x4488ff, 0.5, 50);
|
|
pointLight.position.set(0, 10, 0);
|
|
scene.add(pointLight);
|
|
|
|
// Grid helper
|
|
const gridHelper = new THREE.GridHelper(40, 40, 0x111133, 0x0a0a22);
|
|
gridHelper.position.y = -0.05;
|
|
scene.add(gridHelper);
|
|
|
|
// ── Build treemap layers ──
|
|
const LAYER_HEIGHT = 4.0;
|
|
const LAYER_SIZE = 16;
|
|
const PADDING = 0.15;
|
|
const interactables = [];
|
|
const allMeshes = [];
|
|
|
|
function getNodeSize(node) {
|
|
if (node.lines) return Math.log2(node.lines + 1);
|
|
if (node.children) return node.children.reduce((s, c) => s + getNodeSize(c), 0);
|
|
return 1;
|
|
}
|
|
|
|
function buildLayer(node, depth, offsetX, offsetZ, parentW, parentH) {
|
|
if (!node.children) return;
|
|
|
|
const items = node.children.map(c => ({
|
|
...c,
|
|
size: getNodeSize(c),
|
|
}));
|
|
|
|
const rect = { x: 0, y: 0, w: parentW, h: parentH };
|
|
const layout = squarify(items, rect);
|
|
const y = depth * LAYER_HEIGHT;
|
|
|
|
// Platform
|
|
const platGeo = new THREE.BoxGeometry(parentW + 0.3, 0.08, parentH + 0.3);
|
|
const platMat = new THREE.MeshStandardMaterial({
|
|
color: dirColors[depth % dirColors.length],
|
|
transparent: true, opacity: 0.35, roughness: 0.8
|
|
});
|
|
const platform = new THREE.Mesh(platGeo, platMat);
|
|
platform.position.set(offsetX + parentW / 2, y - 0.04, offsetZ + parentH / 2);
|
|
scene.add(platform);
|
|
allMeshes.push(platform);
|
|
|
|
// Label for this directory
|
|
const labelCanvas = document.createElement('canvas');
|
|
labelCanvas.width = 512; labelCanvas.height = 64;
|
|
const lctx = labelCanvas.getContext('2d');
|
|
lctx.fillStyle = '#88bbff';
|
|
lctx.font = 'bold 28px Courier New';
|
|
lctx.fillText('📁 ' + node.name + '/', 10, 40);
|
|
const labelTex = new THREE.CanvasTexture(labelCanvas);
|
|
const labelGeo = new THREE.PlaneGeometry(parentW * 0.6, parentW * 0.6 * 64 / 512);
|
|
const labelMat = new THREE.MeshBasicMaterial({ map: labelTex, transparent: true, depthWrite: false });
|
|
const labelMesh = new THREE.Mesh(labelGeo, labelMat);
|
|
labelMesh.position.set(offsetX + parentW / 2, y + 0.1, offsetZ - 0.5);
|
|
labelMesh.rotation.x = -Math.PI / 2;
|
|
scene.add(labelMesh);
|
|
allMeshes.push(labelMesh);
|
|
|
|
for (const item of layout) {
|
|
const r = item.rect;
|
|
const px = offsetX + r.x + PADDING / 2;
|
|
const pz = offsetZ + r.y + PADDING / 2;
|
|
const pw = r.w - PADDING;
|
|
const ph = r.h - PADDING;
|
|
|
|
if (pw < 0.1 || ph < 0.1) continue;
|
|
|
|
if (item.children) {
|
|
// Directory → recurse
|
|
buildLayer(item, depth + 1, px, pz, pw, ph);
|
|
|
|
// Small marker on current level
|
|
const mGeo = new THREE.BoxGeometry(pw, 0.15, ph);
|
|
const mMat = new THREE.MeshStandardMaterial({
|
|
color: dirColors[(depth + 1) % dirColors.length],
|
|
transparent: true, opacity: 0.5, roughness: 0.6
|
|
});
|
|
const marker = new THREE.Mesh(mGeo, mMat);
|
|
marker.position.set(px + pw / 2, y + 0.08, pz + ph / 2);
|
|
scene.add(marker);
|
|
allMeshes.push(marker);
|
|
|
|
// Directory label on marker
|
|
const dc = document.createElement('canvas');
|
|
dc.width = 256; dc.height = 48;
|
|
const dctx = dc.getContext('2d');
|
|
dctx.fillStyle = '#66aadd';
|
|
dctx.font = '20px Courier New';
|
|
dctx.fillText('📂 ' + item.name, 5, 30);
|
|
const dtex = new THREE.CanvasTexture(dc);
|
|
const dgeo = new THREE.PlaneGeometry(pw * 0.8, pw * 0.8 * 48 / 256);
|
|
const dmat = new THREE.MeshBasicMaterial({ map: dtex, transparent: true });
|
|
const dlbl = new THREE.Mesh(dgeo, dmat);
|
|
dlbl.position.set(px + pw / 2, y + 0.2, pz + ph / 2);
|
|
dlbl.rotation.x = -Math.PI / 2;
|
|
scene.add(dlbl);
|
|
allMeshes.push(dlbl);
|
|
|
|
} else {
|
|
// File → render code on surface
|
|
const height = 0.12 + Math.log2(item.lines + 1) * 0.04;
|
|
const geo = new THREE.BoxGeometry(pw, height, ph);
|
|
const color = getFileColor(item.name);
|
|
|
|
// Code texture
|
|
const canvas = document.createElement('canvas');
|
|
const res = 512;
|
|
canvas.width = res; canvas.height = res;
|
|
const ctx = canvas.getContext('2d');
|
|
|
|
// Background
|
|
ctx.fillStyle = '#0c0c18';
|
|
ctx.fillRect(0, 0, res, res);
|
|
|
|
// File name header
|
|
ctx.fillStyle = '#5599dd';
|
|
ctx.font = 'bold 14px Courier New';
|
|
ctx.fillText(item.name, 8, 16);
|
|
ctx.fillStyle = '#334455';
|
|
ctx.fillRect(0, 20, res, 1);
|
|
|
|
// Fake code lines
|
|
const codeLines = generateFakeCode(Math.min(item.lines, 40));
|
|
const fontSize = 9;
|
|
ctx.font = `${fontSize}px Courier New`;
|
|
for (let li = 0; li < codeLines.length && li < 50; li++) {
|
|
const line = codeLines[li];
|
|
const yy = 30 + li * (fontSize + 2);
|
|
if (yy > res - 5) break;
|
|
|
|
// Line number
|
|
ctx.fillStyle = '#333355';
|
|
ctx.fillText(String(li + 1).padStart(3), 4, yy);
|
|
|
|
// Syntax coloring
|
|
const tokens = line.split(/(\s+)/);
|
|
let xOff = 30;
|
|
for (const tok of tokens) {
|
|
if (keywords.includes(tok.trim())) ctx.fillStyle = '#cc77dd';
|
|
else if (types.includes(tok.trim())) ctx.fillStyle = '#44bbaa';
|
|
else if (tok.startsWith('//')) ctx.fillStyle = '#446644';
|
|
else if (tok === '{' || tok === '}') ctx.fillStyle = '#ccaa44';
|
|
else ctx.fillStyle = '#8899aa';
|
|
ctx.fillText(tok, xOff, yy);
|
|
xOff += ctx.measureText(tok).width;
|
|
}
|
|
}
|
|
|
|
const texture = new THREE.CanvasTexture(canvas);
|
|
texture.minFilter = THREE.LinearFilter;
|
|
|
|
// Top face gets code texture, sides get solid color
|
|
const materials = [
|
|
new THREE.MeshStandardMaterial({ color, roughness: 0.7 }),
|
|
new THREE.MeshStandardMaterial({ color, roughness: 0.7 }),
|
|
new THREE.MeshStandardMaterial({ map: texture, roughness: 0.5 }),
|
|
new THREE.MeshStandardMaterial({ color: 0x050510, roughness: 0.9 }),
|
|
new THREE.MeshStandardMaterial({ color, roughness: 0.7 }),
|
|
new THREE.MeshStandardMaterial({ color, roughness: 0.7 }),
|
|
];
|
|
|
|
const mesh = new THREE.Mesh(geo, materials);
|
|
mesh.position.set(px + pw / 2, y + height / 2, pz + ph / 2);
|
|
scene.add(mesh);
|
|
allMeshes.push(mesh);
|
|
|
|
// Edge glow
|
|
const edgeGeo = new THREE.EdgesGeometry(geo);
|
|
const edgeMat = new THREE.LineBasicMaterial({ color: 0x3366aa, transparent: true, opacity: 0.3 });
|
|
const edges = new THREE.LineSegments(edgeGeo, edgeMat);
|
|
mesh.add(edges);
|
|
|
|
mesh.userData = {
|
|
type: 'file',
|
|
name: item.name,
|
|
lines: item.lines,
|
|
size: item.size,
|
|
};
|
|
interactables.push(mesh);
|
|
|
|
// Vertical connection line to platform
|
|
if (depth > 0) {
|
|
const lineGeo = new THREE.BufferGeometry().setFromPoints([
|
|
new THREE.Vector3(px + pw / 2, y, pz + ph / 2),
|
|
new THREE.Vector3(px + pw / 2, y - LAYER_HEIGHT + 0.2, pz + ph / 2),
|
|
]);
|
|
const lineMat = new THREE.LineBasicMaterial({ color: 0x223355, transparent: true, opacity: 0.2 });
|
|
scene.add(new THREE.LineSegments(lineGeo, lineMat));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
buildLayer(codebase, 0, -LAYER_SIZE / 2, -LAYER_SIZE / 2, LAYER_SIZE, LAYER_SIZE);
|
|
|
|
// ── Orbit Controls (manual) ──
|
|
let isDragging = false, isRightDrag = false;
|
|
let prevX = 0, prevY = 0;
|
|
let theta = -0.3, phi = 0.8, radius = 30;
|
|
let targetX = 0, targetY = 5, targetZ = 0;
|
|
|
|
function updateCamera() {
|
|
camera.position.x = targetX + radius * Math.sin(phi) * Math.sin(theta);
|
|
camera.position.y = targetY + radius * Math.cos(phi);
|
|
camera.position.z = targetZ + radius * Math.sin(phi) * Math.cos(theta);
|
|
camera.lookAt(targetX, targetY, targetZ);
|
|
}
|
|
updateCamera();
|
|
|
|
renderer.domElement.addEventListener('mousedown', e => {
|
|
if (e.button === 2) isRightDrag = true;
|
|
else isDragging = true;
|
|
prevX = e.clientX; prevY = e.clientY;
|
|
});
|
|
renderer.domElement.addEventListener('mousemove', e => {
|
|
if (isDragging) {
|
|
theta -= (e.clientX - prevX) * 0.005;
|
|
phi = Math.max(0.1, Math.min(Math.PI - 0.1, phi - (e.clientY - prevY) * 0.005));
|
|
prevX = e.clientX; prevY = e.clientY;
|
|
updateCamera();
|
|
}
|
|
if (isRightDrag) {
|
|
const dx = (e.clientX - prevX) * 0.03;
|
|
const dy = (e.clientY - prevY) * 0.03;
|
|
targetX -= dx * Math.cos(theta);
|
|
targetZ += dx * Math.sin(theta);
|
|
targetY += dy;
|
|
prevX = e.clientX; prevY = e.clientY;
|
|
updateCamera();
|
|
}
|
|
});
|
|
window.addEventListener('mouseup', () => { isDragging = false; isRightDrag = false; });
|
|
renderer.domElement.addEventListener('contextmenu', e => e.preventDefault());
|
|
renderer.domElement.addEventListener('wheel', e => {
|
|
radius = Math.max(5, Math.min(80, radius + e.deltaY * 0.02));
|
|
updateCamera();
|
|
});
|
|
|
|
// ── Hover / Click ──
|
|
const raycaster = new THREE.Raycaster();
|
|
const mouse = new THREE.Vector2();
|
|
const tooltip = document.getElementById('tooltip');
|
|
let hoveredMesh = null;
|
|
|
|
renderer.domElement.addEventListener('mousemove', e => {
|
|
mouse.x = (e.clientX / window.innerWidth) * 2 - 1;
|
|
mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
|
|
|
|
raycaster.setFromCamera(mouse, camera);
|
|
const hits = raycaster.intersectObjects(interactables);
|
|
|
|
if (hoveredMesh && hoveredMesh !== (hits[0]?.object)) {
|
|
// un-hover
|
|
if (hoveredMesh.userData.originalY !== undefined) {
|
|
hoveredMesh.position.y = hoveredMesh.userData.originalY;
|
|
}
|
|
hoveredMesh = null;
|
|
tooltip.style.display = 'none';
|
|
}
|
|
|
|
if (hits.length > 0) {
|
|
const obj = hits[0].object;
|
|
if (obj.userData.type === 'file') {
|
|
hoveredMesh = obj;
|
|
if (obj.userData.originalY === undefined) obj.userData.originalY = obj.position.y;
|
|
obj.position.y = obj.userData.originalY + 0.1;
|
|
|
|
tooltip.style.display = 'block';
|
|
tooltip.style.left = (e.clientX + 15) + 'px';
|
|
tooltip.style.top = (e.clientY - 10) + 'px';
|
|
tooltip.textContent = `📄 ${obj.userData.name}\n ${obj.userData.lines} lines\n size: ${obj.userData.size.toFixed(1)}`;
|
|
}
|
|
}
|
|
});
|
|
|
|
// ── WebXR ──
|
|
if (navigator.xr) {
|
|
navigator.xr.isSessionSupported('immersive-vr').then(ok => {
|
|
if (ok) {
|
|
const btn = document.getElementById('vrButton');
|
|
btn.style.display = 'block';
|
|
btn.addEventListener('click', () => {
|
|
navigator.xr.requestSession('immersive-vr', { optionalFeatures: ['local-floor'] }).then(session => {
|
|
renderer.xr.setSession(session);
|
|
btn.style.display = 'none';
|
|
session.addEventListener('end', () => { btn.style.display = 'block'; });
|
|
});
|
|
});
|
|
}
|
|
});
|
|
}
|
|
|
|
// ── Animate ──
|
|
const clock = new THREE.Clock();
|
|
renderer.setAnimationLoop(() => {
|
|
const t = clock.getElapsedTime();
|
|
pointLight.position.x = Math.sin(t * 0.3) * 8;
|
|
pointLight.position.z = Math.cos(t * 0.3) * 8;
|
|
renderer.render(scene, camera);
|
|
});
|
|
|
|
window.addEventListener('resize', () => {
|
|
camera.aspect = window.innerWidth / window.innerHeight;
|
|
camera.updateProjectionMatrix();
|
|
renderer.setSize(window.innerWidth, window.innerHeight);
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|