- Server now returns metadata-only tree on initial load (no file content
in the JSON payload); content is served on-demand via the new
GET /api/repos/{key}/file?path=... endpoint
- Cache still stores full content; strip_content() runs in-memory before
the response is sent
- Frontend fetches file content lazily in _fetchContent() when a tile
enters the LOD view, preventing a massive upfront JSON download for
large repos (e.g. claude code)
- computeColorRanges() is now deferred to first _showCode() call instead
of running synchronously for every file during load()
- Cap label fontSize at 5 world units to prevent giant text on large tiles
465 lines
17 KiB
JavaScript
465 lines
17 KiB
JavaScript
/**
|
|
* 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 =
|
|
`<span>${this.totalFiles} files</span> · <span>${(this.totalLines/1000).toFixed(1)}k lines</span> · ` +
|
|
`<span>zoom ${zoomPct.toFixed(0)}%</span>` + (showing > 0 ? ` · <span>${showing} in view</span>` : "");
|
|
}
|
|
|
|
_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);
|
|
}
|
|
}
|