Initial commit: repo-vis — 3D codebase visualization

Rust (axum) backend with git clone / zip upload / SQLite cache.
Three.js frontend with D3 treemap layout and semantic zoom.
Docker deployment with musl static binary.
This commit is contained in:
2026-04-06 13:30:12 +01:00
commit 7232d4cc37
16 changed files with 4334 additions and 0 deletions

288
web/index.html Normal file
View File

@@ -0,0 +1,288 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>repo-vis</title>
<!-- Terminus (code) + LXGW WenKai Mono (CJK) -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xz/fonts@1/serve/terminus.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/lxgw-wenkai-mono-webfont@1.7.0/style.css">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
font-family: -apple-system, "Segoe UI", Roboto, sans-serif;
background: #11111b;
color: #cdd6f4;
overflow: hidden;
}
/* Landing page */
#landing {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
gap: 32px;
}
#landing h1 {
font-size: 48px;
font-weight: 300;
letter-spacing: -1px;
color: #cba6f7;
}
#landing .subtitle {
font-size: 16px;
color: #6c7086;
margin-top: -20px;
}
.input-group {
display: flex;
flex-direction: column;
gap: 16px;
width: 500px;
max-width: 90vw;
}
.input-row {
display: flex;
gap: 8px;
}
.input-row input[type="text"] {
flex: 1;
padding: 12px 16px;
border: 1px solid #313244;
border-radius: 8px;
background: #1e1e2e;
color: #cdd6f4;
font-size: 15px;
outline: none;
transition: border-color 0.2s;
}
.input-row input[type="text"]:focus {
border-color: #cba6f7;
}
.input-row input[type="text"]::placeholder {
color: #585b70;
}
button {
padding: 12px 24px;
border: none;
border-radius: 8px;
background: #cba6f7;
color: #1e1e2e;
font-size: 15px;
font-weight: 600;
cursor: pointer;
transition: background 0.2s;
}
button:hover { background: #b4befe; }
button:disabled { opacity: 0.5; cursor: not-allowed; }
.divider {
display: flex;
align-items: center;
gap: 16px;
color: #585b70;
font-size: 13px;
}
.divider::before, .divider::after {
content: "";
flex: 1;
height: 1px;
background: #313244;
}
.drop-zone {
border: 2px dashed #313244;
border-radius: 12px;
padding: 32px;
text-align: center;
color: #6c7086;
cursor: pointer;
transition: border-color 0.2s, background 0.2s;
}
.drop-zone:hover, .drop-zone.dragover {
border-color: #cba6f7;
background: rgba(203, 166, 247, 0.05);
}
.drop-zone input { display: none; }
/* Loading state */
#loading {
display: none;
position: fixed;
inset: 0;
background: #11111b;
z-index: 100;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16px;
}
#loading.active { display: flex; }
.spinner {
width: 40px;
height: 40px;
border: 3px solid #313244;
border-top-color: #cba6f7;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
#loading-text { color: #6c7086; font-size: 14px; }
/* Visualization */
#viewport {
display: none;
width: 100vw;
height: 100vh;
}
#viewport.active { display: block; }
/* Tooltip */
#tooltip {
display: none;
position: fixed;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
padding: 8px 16px;
background: rgba(30, 30, 46, 0.9);
backdrop-filter: blur(8px);
border: 1px solid #313244;
border-radius: 8px;
font-size: 13px;
font-family: Terminus, "LXGW WenKai Mono", monospace;
color: #cdd6f4;
pointer-events: none;
z-index: 50;
white-space: nowrap;
}
/* Controls hint */
#controls-hint {
position: fixed;
top: 16px;
right: 16px;
padding: 8px 12px;
background: rgba(30, 30, 46, 0.8);
border: 1px solid #313244;
border-radius: 8px;
font-size: 12px;
color: #585b70;
line-height: 1.6;
z-index: 50;
display: none;
}
#controls-hint.active { display: block; }
/* History */
#history {
display: none;
width: 500px;
max-width: 90vw;
margin-top: 8px;
}
#history.has-items { display: block; }
#history h3 {
font-size: 13px;
font-weight: 500;
color: #585b70;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 1px;
}
.history-list {
display: flex;
flex-direction: column;
gap: 4px;
}
.history-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 14px;
background: #1e1e2e;
border: 1px solid #313244;
border-radius: 8px;
cursor: pointer;
transition: border-color 0.2s;
}
.history-item:hover { border-color: #cba6f7; }
.history-item .name {
font-size: 14px;
font-weight: 500;
color: #cdd6f4;
}
.history-item .meta {
font-size: 12px;
color: #585b70;
}
</style>
</head>
<body>
<div id="landing">
<h1>repo-vis</h1>
<p class="subtitle">Visualize any codebase in 3D</p>
<div class="input-group">
<div class="input-row">
<input type="text" id="git-url" placeholder="https://github.com/user/repo">
<button id="btn-clone">Clone & Visualize</button>
</div>
<div class="divider">or</div>
<div class="drop-zone" id="drop-zone">
<p>Drop a .zip file here, or click to browse</p>
<input type="file" id="file-input" accept=".zip">
</div>
</div>
<div id="history">
<h3>Recent</h3>
<div class="history-list" id="history-list"></div>
</div>
</div>
<div id="loading">
<div class="spinner"></div>
<div id="loading-text">Cloning repository...</div>
</div>
<div id="viewport"></div>
<div id="tooltip"></div>
<div id="controls-hint">
LMB drag — rotate &nbsp;|&nbsp; RMB drag — pan &nbsp;|&nbsp; scroll — zoom &nbsp;|&nbsp; double-click — focus file
</div>
<script type="module" src="/src/app.js"></script>
</body>
</html>

1058
web/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

17
web/package.json Normal file
View File

@@ -0,0 +1,17 @@
{
"name": "repo-vis-web",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build"
},
"dependencies": {
"d3-hierarchy": "^3.1.2",
"three": "^0.170.0"
},
"devDependencies": {
"vite": "^6.0.0"
}
}

181
web/src/app.js Normal file
View File

@@ -0,0 +1,181 @@
import { computeLayout } from "./layout.js";
import { RepoRenderer } from "./renderer.js";
const landing = document.getElementById("landing");
const loading = document.getElementById("loading");
const loadingText = document.getElementById("loading-text");
const viewport = document.getElementById("viewport");
const controlsHint = document.getElementById("controls-hint");
const gitUrlInput = document.getElementById("git-url");
const btnClone = document.getElementById("btn-clone");
const dropZone = document.getElementById("drop-zone");
const fileInput = document.getElementById("file-input");
const historyEl = document.getElementById("history");
const historyList = document.getElementById("history-list");
function showLoading(msg) {
landing.style.display = "none";
loading.classList.add("active");
loadingText.textContent = msg;
}
function showVisualization() {
loading.classList.remove("active");
viewport.classList.add("active");
controlsHint.classList.add("active");
}
function showError(msg) {
loading.classList.remove("active");
landing.style.display = "";
alert(msg);
}
async function visualize(tree) {
showLoading("Building layout...");
// Wait for fonts to load so canvas renders them correctly
await document.fonts.ready;
await new Promise((r) => setTimeout(r, 50));
const { leaves, totalWidth, totalHeight } = computeLayout(tree);
if (leaves.length === 0) {
showError("No source files found in repository.");
return;
}
showLoading(`Rendering ${leaves.length} files...`);
await new Promise((r) => setTimeout(r, 50));
showVisualization();
const renderer = new RepoRenderer(viewport);
renderer.load(leaves, totalWidth, totalHeight);
}
// --- History ---
async function loadHistory() {
try {
const res = await fetch("/api/repos");
if (!res.ok) return;
const repos = await res.json();
if (repos.length === 0) return;
historyEl.classList.add("has-items");
historyList.innerHTML = "";
for (const repo of repos) {
const item = document.createElement("div");
item.className = "history-item";
item.innerHTML = `
<span class="name">${escapeHtml(repo.name)}</span>
<span class="meta">${repo.file_count} files</span>
`;
item.addEventListener("click", () => loadCachedRepo(repo.cache_key, repo.name));
historyList.appendChild(item);
}
} catch {
// ignore
}
}
async function loadCachedRepo(key, name) {
showLoading(`Loading ${name}...`);
try {
const res = await fetch(`/api/repos/${key}`);
if (!res.ok) throw new Error("Cache expired");
const tree = await res.json();
await visualize(tree);
} catch (err) {
showError(err.message);
}
}
function escapeHtml(s) {
const div = document.createElement("div");
div.textContent = s;
return div.innerHTML;
}
// Load history on page load
loadHistory();
// --- Git clone ---
btnClone.addEventListener("click", async () => {
const url = gitUrlInput.value.trim();
if (!url) return;
btnClone.disabled = true;
showLoading("Cloning repository...");
try {
const res = await fetch("/api/scan-git", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ url }),
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Clone failed");
}
const tree = await res.json();
await visualize(tree);
} catch (err) {
showError(err.message);
} finally {
btnClone.disabled = false;
}
});
gitUrlInput.addEventListener("keydown", (e) => {
if (e.key === "Enter") btnClone.click();
});
// --- Zip upload ---
dropZone.addEventListener("click", () => fileInput.click());
dropZone.addEventListener("dragover", (e) => {
e.preventDefault();
dropZone.classList.add("dragover");
});
dropZone.addEventListener("dragleave", () => {
dropZone.classList.remove("dragover");
});
dropZone.addEventListener("drop", (e) => {
e.preventDefault();
dropZone.classList.remove("dragover");
const file = e.dataTransfer.files[0];
if (file) uploadZip(file);
});
fileInput.addEventListener("change", () => {
if (fileInput.files[0]) uploadZip(fileInput.files[0]);
});
async function uploadZip(file) {
showLoading("Uploading and scanning zip...");
try {
const form = new FormData();
form.append("file", file);
const res = await fetch("/api/scan-zip", {
method: "POST",
body: form,
});
if (!res.ok) {
const err = await res.json();
throw new Error(err.error || "Upload failed");
}
const tree = await res.json();
await visualize(tree);
} catch (err) {
showError(err.message);
}
}

50
web/src/layout.js Normal file
View File

@@ -0,0 +1,50 @@
/**
* Treemap layout using d3-hierarchy.
* Each file rectangle is sized by: lines × maxLen (the "true" code area).
* Returns a flat array of positioned file nodes.
*/
import { treemap, treemapSquarify, hierarchy } from "d3-hierarchy";
/**
* @param {object} tree - The scanned repo tree from scanner.js
* @param {number} charW - Character width in world units
* @param {number} charH - Character height (line height) in world units
* @param {number} padding - Padding between files in world units
* @returns {Array<{path, name, x, z, w, h, lines, maxLen, content}>}
*/
export function computeLayout(tree, charW = 0.15, charH = 0.3, padding = 2) {
const root = hierarchy(tree)
.sum((d) => (d.content != null ? d.lines * d.maxLen : 0))
.sort((a, b) => b.value - a.value);
const totalValue = root.value;
// Scale factor: we want roughly sqrt(totalValue) * charW on each side
const scale = Math.sqrt(totalValue) * charW * 1.2;
const tm = treemap()
.tile(treemapSquarify.ratio(1))
.size([scale, scale])
.paddingInner(padding)
.paddingOuter(padding);
tm(root);
const leaves = [];
for (const leaf of root.leaves()) {
if (!leaf.data.content) continue;
leaves.push({
path: leaf.data.path,
name: leaf.data.name,
x: leaf.x0,
z: leaf.y0,
w: leaf.x1 - leaf.x0,
h: leaf.y1 - leaf.y0,
lines: leaf.data.lines,
maxLen: leaf.data.maxLen,
content: leaf.data.content,
});
}
return { leaves, totalWidth: scale, totalHeight: scale };
}

373
web/src/renderer.js Normal file
View File

@@ -0,0 +1,373 @@
/**
* Three.js renderer for the repo visualization.
* - Files are flat planes on the XZ ground plane
* - 3D OrbitControls camera
* - Semantic zoom: color blocks → code texture based on camera distance
*/
import * as THREE from "three";
import { OrbitControls } from "three/addons/controls/OrbitControls.js";
// Color palette by file extension
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;
function getColor(filename) {
const dot = filename.lastIndexOf(".");
if (dot === -1) return DEFAULT_COLOR;
const ext = filename.substring(dot).toLowerCase();
return EXT_COLORS[ext] ?? DEFAULT_COLOR;
}
/**
* Render code text onto a Canvas and return it as a texture.
*/
// Monospace font stack with CJK fallback
const CODE_FONT = 'Terminus, "LXGW WenKai Mono", "Noto Sans Mono CJK SC", "Microsoft YaHei", monospace';
function createCodeTexture(content, lines, maxLen) {
const fontSize = 28; // Higher base resolution for sharp zoom
const charW = fontSize * 0.6;
const lineH = fontSize * 1.4;
// Wrap lines that exceed maxLen
const rawLines = content.split("\n");
const wrappedLines = [];
for (const line of rawLines) {
if (line.length <= maxLen) {
wrappedLines.push(line);
} else {
for (let j = 0; j < line.length; j += maxLen) {
wrappedLines.push(line.substring(j, j + maxLen));
}
}
}
const canvasW = Math.ceil(maxLen * charW) + 20;
const canvasH = Math.ceil(wrappedLines.length * lineH) + 20;
// Cap canvas size — 8192 for sharper textures on modern GPUs
const maxDim = 8192;
const scaleX = canvasW > maxDim ? maxDim / canvasW : 1;
const scaleY = canvasH > maxDim ? maxDim / canvasH : 1;
const scale = Math.min(scaleX, scaleY);
const w = Math.ceil(canvasW * scale);
const h = Math.ceil(canvasH * scale);
const canvas = document.createElement("canvas");
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext("2d");
// Dark background
ctx.fillStyle = "#1e1e2e";
ctx.fillRect(0, 0, w, h);
ctx.scale(scale, scale);
ctx.font = `${fontSize}px ${CODE_FONT}`;
ctx.fillStyle = "#cdd6f4";
ctx.textBaseline = "top";
for (let i = 0; i < wrappedLines.length; i++) {
ctx.fillText(wrappedLines[i], 10, 10 + i * lineH);
}
const tex = new THREE.CanvasTexture(canvas);
tex.minFilter = THREE.LinearMipmapLinearFilter;
tex.magFilter = THREE.LinearFilter;
tex.anisotropy = 8;
return tex;
}
/**
* Create a simple color block texture with the file name.
*/
function createBlockTexture(name, color) {
const canvas = document.createElement("canvas");
canvas.width = 256;
canvas.height = 256;
const ctx = canvas.getContext("2d");
const hex = "#" + color.toString(16).padStart(6, "0");
ctx.fillStyle = hex;
ctx.fillRect(0, 0, 256, 256);
// Slightly darker border
ctx.strokeStyle = "rgba(0,0,0,0.3)";
ctx.lineWidth = 4;
ctx.strokeRect(2, 2, 252, 252);
// File name
ctx.fillStyle = "#ffffff";
ctx.font = "bold 18px sans-serif";
ctx.textAlign = "center";
ctx.textBaseline = "middle";
// Truncate long names
const label = name.length > 28 ? name.substring(0, 25) + "..." : name;
ctx.fillText(label, 128, 128);
const tex = new THREE.CanvasTexture(canvas);
tex.minFilter = THREE.LinearFilter;
return tex;
}
/**
* @typedef {Object} FileTile
* @property {THREE.Mesh} mesh
* @property {object} data - layout data (path, name, content, etc.)
* @property {THREE.Texture|null} codeTexture - lazily created
* @property {THREE.Texture} blockTexture
* @property {boolean} showingCode
*/
export class RepoRenderer {
constructor(container) {
this.container = container;
this.tiles = [];
this.raycaster = new THREE.Raycaster();
this.mouse = new THREE.Vector2();
this.hoveredTile = null;
this._initScene();
this._initControls();
this._initEvents();
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);
// Ambient + directional light
this.scene.add(new THREE.AmbientLight(0xffffff, 0.7));
const dirLight = new THREE.DirectionalLight(0xffffff, 0.5);
dirLight.position.set(100, 200, 100);
this.scene.add(dirLight);
}
_initControls() {
this.controls = new OrbitControls(this.camera, this.renderer3d.domElement);
this.controls.enableDamping = true;
this.controls.dampingFactor = 0.1;
this.controls.maxPolarAngle = Math.PI / 2.1; // Don't go below the ground
this.controls.minDistance = 1;
this.controls.maxDistance = 10000;
this.controls.zoomSpeed = 1.5;
this.controls.target.set(0, 0, 0);
// Google Maps-style touch: 1 finger pan, 2 finger pinch zoom + rotate
this.controls.touches = {
ONE: THREE.TOUCH.PAN,
TWO: THREE.TOUCH.DOLLY_ROTATE,
};
}
_initEvents() {
window.addEventListener("resize", () => {
this.camera.aspect = window.innerWidth / window.innerHeight;
this.camera.updateProjectionMatrix();
this.renderer3d.setSize(window.innerWidth, window.innerHeight);
});
// Hover for tooltip
this.renderer3d.domElement.addEventListener("mousemove", (e) => {
this.mouse.x = (e.clientX / window.innerWidth) * 2 - 1;
this.mouse.y = -(e.clientY / window.innerHeight) * 2 + 1;
});
// Double-click to zoom into a file
this.renderer3d.domElement.addEventListener("dblclick", () => {
if (this.hoveredTile) {
const t = this.hoveredTile;
const d = t.data;
const targetX = d.x + d.w / 2;
const targetZ = d.z + d.h / 2;
const viewDist = Math.max(d.w, d.h) * 0.8;
// Animate camera
this._animateTo(
new THREE.Vector3(targetX, viewDist, targetZ + viewDist * 0.5),
new THREE.Vector3(targetX, 0, targetZ)
);
}
});
}
_animateTo(position, target, duration = 800) {
const startPos = this.camera.position.clone();
const startTarget = this.controls.target.clone();
const startTime = performance.now();
const step = () => {
const elapsed = performance.now() - startTime;
const t = Math.min(elapsed / duration, 1);
// Smooth ease
const ease = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
this.camera.position.lerpVectors(startPos, position, ease);
this.controls.target.lerpVectors(startTarget, target, ease);
if (t < 1) requestAnimationFrame(step);
};
requestAnimationFrame(step);
}
/**
* Load layout data and create meshes.
*/
load(leaves, totalWidth, totalHeight) {
// Center the layout around origin
const offsetX = -totalWidth / 2;
const offsetZ = -totalHeight / 2;
for (const leaf of leaves) {
const color = getColor(leaf.name);
// Block texture (far LOD)
const blockTex = createBlockTexture(leaf.name, color);
const material = new THREE.MeshStandardMaterial({
map: blockTex,
roughness: 0.8,
metalness: 0.1,
});
const geometry = new THREE.PlaneGeometry(leaf.w, leaf.h);
const mesh = new THREE.Mesh(geometry, material);
// Lay flat on XZ plane, slight Y offset to avoid z-fighting
mesh.rotation.x = -Math.PI / 2;
mesh.position.set(
offsetX + leaf.x + leaf.w / 2,
0.01,
offsetZ + leaf.z + leaf.h / 2
);
this.scene.add(mesh);
// Adjust leaf coords to world space for camera targeting
leaf.x += offsetX;
leaf.z += offsetZ;
this.tiles.push({
mesh,
data: leaf,
codeTexture: null,
blockTexture: blockTex,
showingCode: false,
color,
});
}
// Set initial camera to see the whole scene
const maxDim = Math.max(totalWidth, totalHeight);
this.camera.position.set(0, maxDim * 0.7, maxDim * 0.5);
this.controls.target.set(0, 0, 0);
// Tooltip element
this.tooltip = document.getElementById("tooltip");
}
_updateLOD() {
const camPos = this.camera.position;
for (const tile of this.tiles) {
const cx = tile.data.x + tile.data.w / 2;
const cz = tile.data.z + tile.data.h / 2;
const dist = Math.sqrt(
(camPos.x - cx) ** 2 + camPos.y ** 2 + (camPos.z - cz) ** 2
);
const fileSize = Math.max(tile.data.w, tile.data.h);
// Switch to code texture when close enough to read
const threshold = fileSize * 3;
if (dist < threshold && !tile.showingCode) {
// Create code texture lazily
if (!tile.codeTexture) {
tile.codeTexture = createCodeTexture(
tile.data.content,
tile.data.lines,
tile.data.maxLen
);
}
tile.mesh.material.map = tile.codeTexture;
tile.mesh.material.needsUpdate = true;
tile.showingCode = true;
} else if (dist >= threshold && tile.showingCode) {
tile.mesh.material.map = tile.blockTexture;
tile.mesh.material.needsUpdate = true;
tile.showingCode = false;
}
}
}
_updateHover() {
this.raycaster.setFromCamera(this.mouse, this.camera);
const meshes = this.tiles.map((t) => t.mesh);
const intersects = this.raycaster.intersectObjects(meshes);
if (intersects.length > 0) {
const mesh = intersects[0].object;
const tile = this.tiles.find((t) => t.mesh === mesh);
this.hoveredTile = tile;
if (this.tooltip && tile) {
this.tooltip.style.display = "block";
this.tooltip.textContent = `${tile.data.path} (${tile.data.lines} lines)`;
}
} else {
this.hoveredTile = null;
if (this.tooltip) this.tooltip.style.display = "none";
}
}
_animate() {
requestAnimationFrame(() => this._animate());
this.controls.update();
this._updateLOD();
this._updateHover();
this.renderer3d.render(this.scene, this.camera);
}
}

12
web/vite.config.js Normal file
View File

@@ -0,0 +1,12 @@
import { defineConfig } from "vite";
export default defineConfig({
server: {
proxy: {
"/api": "http://localhost:3000",
},
},
build: {
outDir: "dist",
},
});