/** * Three.js renderer for repo-vis. * troika-three-text (MSDF) for text. Lazy text pool for performance. */ import * as THREE from "three"; import { OrbitControls } from "three/addons/controls/OrbitControls.js"; import { Text } from "troika-three-text"; import { computeColorRanges } from "./highlight.js"; // ---------- colors ---------- const EXT_COLORS = { ".js": 0xf7df1e, ".jsx": 0x61dafb, ".ts": 0x3178c6, ".tsx": 0x61dafb, ".py": 0x3572a5, ".go": 0x00add8, ".rs": 0xdea584, ".c": 0x555555, ".h": 0x555555, ".cpp": 0xf34b7d, ".hpp": 0xf34b7d, ".java": 0xb07219, ".rb": 0xcc342d, ".php": 0x4f5d95, ".html": 0xe34c26, ".css": 0x563d7c, ".scss": 0xc6538c, ".json": 0x40b5a4, ".yaml": 0xcb171e, ".yml": 0xcb171e, ".md": 0x083fa1, ".sh": 0x89e051, ".sql": 0xe38c00, ".vue": 0x41b883, ".svelte": 0xff3e00, }; const DEFAULT_COLOR = 0x8899aa; const MAX_VISIBLE_CODE = 25; // max concurrent code text meshes function getColor(fn) { const i = fn.lastIndexOf("."); return i < 0 ? DEFAULT_COLOR : (EXT_COLORS[fn.substring(i).toLowerCase()] ?? DEFAULT_COLOR); } /** Build a tiled watermark string that fills a rectangular area at 45°. */ function buildWatermark(text, cols, rows) { const padded = text + " "; const line = padded.repeat(Math.ceil(cols / padded.length)).substring(0, cols); const lines = []; for (let i = 0; i < rows; i++) { // Offset each line to create diagonal illusion const shift = (i * 3) % padded.length; lines.push(line.substring(shift) + line.substring(0, shift)); } return lines.join("\n"); } // ---------- renderer ---------- export class RepoRenderer { constructor(container, repoName, cacheKey) { this.container = container; this.repoName = repoName || "repo"; this.cacheKey = cacheKey || null; this.tiles = []; this.bgMeshes = []; this.raycaster = new THREE.Raycaster(); this.mouse = new THREE.Vector2(); this.hoveredTile = null; this.initialCamPos = null; this.initialCamTarget = null; this.totalFiles = 0; this.totalLines = 0; this.frameCount = 0; // Tiles currently showing code (sorted by distance, closest first) this.activeTiles = []; this._initScene(); this._initControls(); this._initEvents(); this._initOSD(); this._animate(); } _initScene() { this.scene = new THREE.Scene(); this.scene.background = new THREE.Color(0x11111b); this.camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 50000); this.camera.position.set(0, 200, 200); this.camera.lookAt(0, 0, 0); this.renderer3d = new THREE.WebGLRenderer({ antialias: true }); this.renderer3d.setSize(window.innerWidth, window.innerHeight); this.renderer3d.setPixelRatio(Math.min(window.devicePixelRatio, 2)); this.container.appendChild(this.renderer3d.domElement); this.scene.add(new THREE.AmbientLight(0xffffff, 1.0)); const d = new THREE.DirectionalLight(0xffffff, 0.5); d.position.set(100, 300, 100); this.scene.add(d); } _initControls() { const c = this.controls = new OrbitControls(this.camera, this.renderer3d.domElement); c.enableDamping = true; c.dampingFactor = 0.1; c.maxPolarAngle = Math.PI / 2.1; c.minDistance = 0.5; c.maxDistance = 10000; c.target.set(0, 0, 0); c.touches = { ONE: THREE.TOUCH.PAN, TWO: THREE.TOUCH.DOLLY_ROTATE }; // Wheel: trackpad vs mouse detection c.enableZoom = false; let wheelEvents = 0, lastWheelT = 0, trackpadMode = false; this.renderer3d.domElement.addEventListener("wheel", (e) => { e.preventDefault(); const now = performance.now(); if (now - lastWheelT < 80) { wheelEvents++; if (wheelEvents > 2) trackpadMode = true; } else { wheelEvents = 1; trackpadMode = false; } lastWheelT = now; if (e.deltaMode >= 1) trackpadMode = false; if (e.ctrlKey || !trackpadMode) { const z = e.deltaY > 0 ? 1.06 : 1 / 1.06; this.camera.position.sub(c.target).multiplyScalar(z).add(c.target); } else { const s = this.camera.position.y * 0.002; const fwd = new THREE.Vector3(); this.camera.getWorldDirection(fwd); fwd.y = 0; fwd.normalize(); const rt = new THREE.Vector3().crossVectors(fwd, new THREE.Vector3(0, 1, 0)).normalize(); const d = rt.clone().multiplyScalar(e.deltaX * s).add(fwd.clone().multiplyScalar(-e.deltaY * s)); this.camera.position.add(d); c.target.add(d); } }, { passive: false }); this.keys = {}; this.moveSpeed = 0.8; window.addEventListener("keydown", (e) => { if (e.target.id === "search-input") return; this.keys[e.code] = true; if (e.key === "?" || (e.shiftKey && e.code === "Slash")) { e.preventDefault(); this._toggleHelp(); return; } if (e.code === "Escape") this._resetCamera(); if (e.code === "Space") { e.preventDefault(); this._spaceZoomOut(); } if (e.code === "Slash" || (e.ctrlKey && e.code === "KeyF")) { e.preventDefault(); this._toggleSearch(); } }); window.addEventListener("keyup", (e) => { this.keys[e.code] = false; }); } _initEvents() { window.addEventListener("resize", () => { this.camera.aspect = window.innerWidth / window.innerHeight; this.camera.updateProjectionMatrix(); this.renderer3d.setSize(window.innerWidth, window.innerHeight); }); this.renderer3d.domElement.addEventListener("mousemove", (e) => { this.mouse.x = (e.clientX / window.innerWidth) * 2 - 1; this.mouse.y = -(e.clientY / window.innerHeight) * 2 + 1; }); this.renderer3d.domElement.addEventListener("dblclick", () => { if (this.hoveredTile) this.flyToTile(this.hoveredTile); }); } _initOSD() { this.osdInfo = document.getElementById("osd-info"); this.osdBreadcrumb = document.getElementById("osd-breadcrumb"); this.searchOverlay = document.getElementById("search-overlay"); this.searchInput = document.getElementById("search-input"); this.searchResults = document.getElementById("search-results"); if (this.searchInput) { this.searchInput.addEventListener("input", () => this._onSearch()); this.searchInput.addEventListener("keydown", (e) => { if (e.code === "Escape") this._toggleSearch(false); if (e.code === "Enter") this._searchSelect(); }); } } // -------- helpers -------- _toggleHelp() { const el = document.getElementById("help-modal"); if (el) el.style.display = el.style.display === "none" ? "flex" : "none"; } _animateTo(pos, tgt, dur = 800) { const sp = this.camera.position.clone(), st = this.controls.target.clone(), t0 = performance.now(); const step = () => { const t = Math.min((performance.now() - t0) / dur, 1); const e = t < 0.5 ? 4*t*t*t : 1 - Math.pow(-2*t+2,3)/2; this.camera.position.lerpVectors(sp, pos, e); this.controls.target.lerpVectors(st, tgt, e); if (t < 1) requestAnimationFrame(step); }; requestAnimationFrame(step); } _resetCamera() { if (this.initialCamPos) this._animateTo(this.initialCamPos.clone(), this.initialCamTarget.clone(), 600); } _spaceZoomOut() { if (this.hoveredTile) this.flyToTile(this.hoveredTile); else this._resetCamera(); } flyToTile(tile) { const d = tile.data, cx = d.x + d.w/2, cz = d.z + d.h/2, dist = Math.max(d.w, d.h) * 0.8; this._animateTo(new THREE.Vector3(cx, dist, cz + dist*0.5), new THREE.Vector3(cx, 0, cz)); } _toggleSearch(show) { if (!this.searchOverlay) return; const vis = show ?? this.searchOverlay.style.display === "none"; this.searchOverlay.style.display = vis ? "flex" : "none"; if (vis) { this.searchInput.value = ""; this.searchInput.focus(); this.searchResults.innerHTML = ""; } } _onSearch() { const q = this.searchInput.value.toLowerCase().trim(); this.searchResults.innerHTML = ""; if (q.length < 2) return; for (const t of this.tiles.filter(t => t.data.path.toLowerCase().includes(q)).slice(0, 12)) { const el = document.createElement("div"); el.className = "search-item"; el.textContent = t.data.path; el.addEventListener("click", () => { this.flyToTile(t); this._toggleSearch(false); }); this.searchResults.appendChild(el); } } _searchSelect() { const f = this.searchResults.querySelector(".search-item"); if (f) f.click(); } // -------- load -------- async load(leaves, totalWidth, totalHeight) { const ox = -totalWidth / 2, oz = -totalHeight / 2; this.totalFiles = leaves.length; this.totalLines = leaves.reduce((s, l) => s + l.lines, 0); // Shared dark material const darkMat = new THREE.MeshBasicMaterial({ color: 0x1e1e2e }); for (const leaf of leaves) { const color = getColor(leaf.name); leaf.x += ox; leaf.z += oz; // --- BG plane (always visible) --- const bgMesh = new THREE.Mesh( new THREE.PlaneGeometry(leaf.w, leaf.h), new THREE.MeshStandardMaterial({ color, roughness: 0.9, metalness: 0.05 }) ); bgMesh.rotation.x = -Math.PI / 2; bgMesh.position.set(leaf.x + leaf.w/2, 0.01, leaf.z + leaf.h/2); this.scene.add(bgMesh); // --- Label (always visible, cheap — one per file) --- const label = new Text(); label.text = leaf.name; label.fontSize = Math.min(Math.min(leaf.w, leaf.h) * 0.15, 5); label.color = 0xffffff; label.anchorX = "center"; label.anchorY = "middle"; label.rotation.x = -Math.PI / 2; label.position.set(leaf.x + leaf.w/2, 0.5, leaf.z + leaf.h/2); label.renderOrder = 10; this.scene.add(label); label.sync(); this.tiles.push({ bgMesh, label, darkMat, codeMesh: null, watermark: null, darkMesh: null, // colorRanges computed lazily on first _showCode colorRanges: undefined, data: leaf, showingCode: false, loading: false, color, dist: Infinity }); this.bgMeshes.push(bgMesh); } const maxDim = Math.max(totalWidth, totalHeight); this.camera.position.set(0, maxDim * 0.7, maxDim * 0.5); this.controls.target.set(0, 0, 0); this.initialCamPos = this.camera.position.clone(); this.initialCamTarget = this.controls.target.clone(); this.tooltip = document.getElementById("tooltip"); } // -------- lazy content fetch -------- async _fetchContent(tile) { try { const res = await fetch( `/api/repos/${encodeURIComponent(this.cacheKey)}/file?path=${encodeURIComponent(tile.data.path)}` ); if (res.ok) { const { content } = await res.json(); tile.data.content = content; // Pre-compute colorRanges right after fetch (off the hot animation path) tile.colorRanges = computeColorRanges(content, tile.data.name); } } catch { // network error — leave content null, will retry next LOD cycle } finally { tile.loading = false; } } // -------- lazy code/watermark creation -------- _showCode(tile) { const d = tile.data; // If content hasn't been loaded yet, kick off a fetch and bail if (!d.content) { if (!tile.loading) { tile.loading = true; if (this.cacheKey) this._fetchContent(tile); } return; } // Compute colorRanges lazily (only once, synchronous after content is available) if (tile.colorRanges === undefined) { tile.colorRanges = computeColorRanges(d.content, d.name); } // Dark bg if (!tile.darkMesh) { tile.darkMesh = new THREE.Mesh(new THREE.PlaneGeometry(d.w, d.h), tile.darkMat); tile.darkMesh.rotation.x = -Math.PI / 2; tile.darkMesh.position.set(d.x + d.w/2, 0.8, d.z + d.h/2); } this.scene.add(tile.darkMesh); // Watermark — tiled path text, 45° rotated, slightly larger than code font if (!tile.watermark) { const codeFontSize = (d.h / d.lines) * 0.65; const wmFontSize = codeFontSize * 2.5; const wmLabel = `${this.repoName}/${d.path}`; // Estimate how many repetitions to fill the area const charsPerLine = Math.ceil(Math.max(d.w, d.h) * 1.5 / (wmFontSize * 0.5)); const lineCount = Math.ceil(Math.max(d.w, d.h) * 1.5 / (wmFontSize * 1.5)); const wmContent = buildWatermark(wmLabel, charsPerLine, lineCount); const wm = new Text(); wm.text = wmContent; wm.fontSize = wmFontSize; wm.lineHeight = 1.5; wm.color = 0x585b70; wm.fillOpacity = 0.08; wm.anchorX = "center"; wm.anchorY = "middle"; wm.rotation.x = -Math.PI / 2; wm.rotation.z = -Math.PI / 4; wm.position.set(d.x + d.w/2, 0.85, d.z + d.h/2); wm.renderOrder = 15; wm.clipRect = [d.x, -(d.z + d.h), d.x + d.w, -d.z]; // clip to file bounds tile.watermark = wm; wm.sync(); } this.scene.add(tile.watermark); // Code text if (!tile.codeMesh) { const codeFontSize = (d.h / d.lines) * 0.65; const cm = new Text(); cm.text = d.content; cm.fontSize = codeFontSize; cm.lineHeight = 1.35; cm.color = 0xcdd6f4; cm.anchorX = "left"; cm.anchorY = "top"; cm.whiteSpace = "pre-wrap"; cm.maxWidth = d.w * 0.96; cm.overflowWrap = "break-word"; cm.rotation.x = -Math.PI / 2; cm.position.set(d.x + d.w * 0.02, 1.0, d.z + d.h * 0.02); cm.renderOrder = 20; if (tile.colorRanges) cm.colorRanges = tile.colorRanges; tile.codeMesh = cm; cm.sync(); } this.scene.add(tile.codeMesh); tile.label.visible = false; tile.showingCode = true; } _hideCode(tile) { if (tile.codeMesh) this.scene.remove(tile.codeMesh); if (tile.watermark) this.scene.remove(tile.watermark); if (tile.darkMesh) this.scene.remove(tile.darkMesh); tile.label.visible = true; tile.showingCode = false; } // -------- LOD (throttled) -------- _updateLOD() { const cam = this.camera.position; // Compute distances for (const t of this.tiles) { const d = t.data; const cx = d.x + d.w/2, cz = d.z + d.h/2; t.dist = Math.sqrt((cam.x-cx)**2 + cam.y**2 + (cam.z-cz)**2); } // Determine which tiles should show code (closest N within threshold) const candidates = this.tiles .filter(t => t.dist < Math.max(t.data.w, t.data.h) * 4) .sort((a, b) => a.dist - b.dist) .slice(0, MAX_VISIBLE_CODE); const shouldShow = new Set(candidates); // Hide tiles that are no longer candidates for (const t of this.tiles) { if (t.showingCode && !shouldShow.has(t)) { this._hideCode(t); } } // Show new candidates for (const t of candidates) { if (!t.showingCode) this._showCode(t); } this.activeTiles = candidates; } // -------- hover -------- _updateHover() { this.raycaster.setFromCamera(this.mouse, this.camera); const hits = this.raycaster.intersectObjects(this.bgMeshes); if (hits.length > 0) { const tile = this.tiles.find(t => t.bgMesh === hits[0].object); this.hoveredTile = tile; if (this.tooltip && tile) { this.tooltip.style.display = "block"; this.tooltip.textContent = `${tile.data.path} (${tile.data.lines} lines)`; } if (this.osdBreadcrumb && tile) { this.osdBreadcrumb.textContent = `${this.repoName}/${tile.data.path}`; this.osdBreadcrumb.style.display = "block"; } } else { this.hoveredTile = null; if (this.tooltip) this.tooltip.style.display = "none"; if (this.osdBreadcrumb) this.osdBreadcrumb.style.display = "none"; } } _updateOSD() { if (!this.osdInfo) return; const h = this.camera.position.y; const zoomPct = Math.max(0, Math.min(100, 100 - (h / (this.initialCamPos?.y || 200)) * 100)); const showing = this.activeTiles.length; this.osdInfo.innerHTML = `${this.totalFiles} files · ${(this.totalLines/1000).toFixed(1)}k lines · ` + `zoom ${zoomPct.toFixed(0)}%` + (showing > 0 ? ` · ${showing} in view` : ""); } _updateMovement() { const speed = this.moveSpeed * (this.camera.position.y * 0.01 + 0.5); const fwd = new THREE.Vector3(); this.camera.getWorldDirection(fwd); fwd.y = 0; fwd.normalize(); const rt = new THREE.Vector3().crossVectors(fwd, new THREE.Vector3(0,1,0)).normalize(); const d = new THREE.Vector3(); if (this.keys["KeyW"]) d.add(fwd.clone().multiplyScalar(speed)); if (this.keys["KeyS"]) d.add(fwd.clone().multiplyScalar(-speed)); if (this.keys["KeyA"]) d.add(rt.clone().multiplyScalar(-speed)); if (this.keys["KeyD"]) d.add(rt.clone().multiplyScalar(speed)); if (this.keys["KeyQ"] || this.keys["KeyE"]) { const angle = (this.keys["KeyQ"] ? 1 : -1) * 0.03; const offset = this.camera.position.clone().sub(this.controls.target); offset.applyAxisAngle(new THREE.Vector3(0,1,0), angle); this.camera.position.copy(this.controls.target).add(offset); } if (this.keys["KeyZ"] || this.keys["KeyC"]) { const f = this.keys["KeyZ"] ? 0.97 : 1.03; this.camera.position.sub(this.controls.target).multiplyScalar(f).add(this.controls.target); } if (d.lengthSq() > 0) { this.camera.position.add(d); this.controls.target.add(d); } } _animate() { requestAnimationFrame(() => this._animate()); this._updateMovement(); this.controls.update(); // Throttle heavy updates: LOD every 3 frames, OSD every 10 this.frameCount++; if (this.frameCount % 3 === 0) this._updateLOD(); if (this.frameCount % 10 === 0) this._updateOSD(); this._updateHover(); this.renderer3d.render(this.scene, this.camera); } }