From 4aec9510e4d7b705ba7618b1f7b13b9487871d30 Mon Sep 17 00:00:00 2001 From: Fam Zheng Date: Mon, 6 Apr 2026 16:30:28 +0100 Subject: [PATCH] Add syntax highlighting, OSD, search, perf optimizations, and UX improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - troika-three-text MSDF rendering for resolution-independent code text - highlight.js syntax highlighting with Catppuccin Mocha colors - Lazy text pool: max 25 concurrent code meshes, created on demand - LOD throttled to every 3 frames, OSD every 10 - 45° tiled watermark (repo/path/filename) behind code - OSD: breadcrumb, file stats, zoom level - Search: / or Ctrl+F to find and fly to files - Keybindings: WASD pan, Q/E rotate, Z/C zoom, Space overview, ? help modal - Mouse wheel zoom vs trackpad pan detection via event frequency - Zip GBK filename encoding fallback for Chinese filenames - Docker volume persistence for SQLite cache --- server/Cargo.lock | 1 + server/Cargo.toml | 1 + server/src/main.rs | 37 ++- web/index.html | 167 +++++++++++- web/package-lock.json | 60 ++++- web/package.json | 4 +- web/src/app.js | 9 +- web/src/highlight.js | 191 ++++++++++++++ web/src/renderer.js | 588 +++++++++++++++++++++++------------------- 9 files changed, 781 insertions(+), 277 deletions(-) create mode 100644 web/src/highlight.js diff --git a/server/Cargo.lock b/server/Cargo.lock index 7750b93..5c19e14 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -900,6 +900,7 @@ name = "repo-vis-server" version = "0.1.0" dependencies = [ "axum", + "encoding_rs", "hex", "rusqlite", "serde", diff --git a/server/Cargo.toml b/server/Cargo.toml index c5a170e..7b20d64 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -14,6 +14,7 @@ walkdir = "2" sha2 = "0.10" hex = "0.4" zip = "2" +encoding_rs = "0.8" tempfile = "3" tracing = "0.1" tracing-subscriber = "0.3" diff --git a/server/src/main.rs b/server/src/main.rs index ff5d280..f7c89b0 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -183,9 +183,40 @@ async fn scan_zip( let mut archive = zip::ZipArchive::new(cursor).map_err(|e| (StatusCode::BAD_REQUEST, format!("Invalid zip: {e}")))?; - archive - .extract(tmp.path()) - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Extract failed: {e}")))?; + // Extract manually to handle non-UTF-8 filenames (GBK from Windows zips) + for i in 0..archive.len() { + let mut file = archive + .by_index_raw(i) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Zip entry error: {e}")))?; + + let raw_name = file.name_raw(); + // Try UTF-8 first, then GBK fallback + let name = match std::str::from_utf8(raw_name) { + Ok(s) => s.to_string(), + Err(_) => { + let (decoded, _, _) = encoding_rs::GBK.decode(raw_name); + decoded.into_owned() + } + }; + + // Sanitize: skip entries that try to escape + if name.contains("..") { + continue; + } + + let out_path = tmp.path().join(&name); + if file.is_dir() { + std::fs::create_dir_all(&out_path).ok(); + } else { + if let Some(parent) = out_path.parent() { + std::fs::create_dir_all(parent).ok(); + } + let mut outfile = std::fs::File::create(&out_path) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Create file: {e}")))?; + std::io::copy(&mut file, &mut outfile) + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Write file: {e}")))?; + } + } let entries: Vec<_> = std::fs::read_dir(tmp.path()) .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))? diff --git a/web/index.html b/web/index.html index 83effca..f73789f 100644 --- a/web/index.html +++ b/web/index.html @@ -186,15 +186,150 @@ background: rgba(30, 30, 46, 0.8); border: 1px solid #313244; border-radius: 8px; - font-size: 12px; + font-size: 11px; color: #585b70; - line-height: 1.6; + line-height: 1.8; z-index: 50; display: none; } #controls-hint.active { display: block; } + /* OSD info bar (bottom-left) */ + #osd-info { + position: fixed; + bottom: 16px; + left: 16px; + padding: 6px 12px; + background: rgba(30, 30, 46, 0.8); + border: 1px solid #313244; + border-radius: 8px; + font-size: 12px; + color: #6c7086; + z-index: 50; + display: none; + font-family: monospace; + } + + #osd-info.active { display: block; } + + /* Breadcrumb (top-left) */ + #osd-breadcrumb { + display: none; + position: fixed; + top: 16px; + left: 16px; + padding: 6px 12px; + background: rgba(30, 30, 46, 0.85); + backdrop-filter: blur(8px); + border: 1px solid #313244; + border-radius: 8px; + font-size: 13px; + font-family: monospace; + color: #89b4fa; + z-index: 50; + pointer-events: none; + } + + /* Search overlay */ + #search-overlay { + display: none; + position: fixed; + top: 0; + left: 50%; + transform: translateX(-50%); + width: 480px; + max-width: 90vw; + margin-top: 60px; + flex-direction: column; + z-index: 200; + } + + #search-input { + padding: 12px 16px; + border: 1px solid #585b70; + border-radius: 10px 10px 0 0; + background: rgba(30, 30, 46, 0.95); + backdrop-filter: blur(12px); + color: #cdd6f4; + font-size: 15px; + font-family: monospace; + outline: none; + } + + #search-input:focus { border-color: #cba6f7; } + #search-input::placeholder { color: #585b70; } + + #search-results { + background: rgba(30, 30, 46, 0.95); + border: 1px solid #585b70; + border-top: none; + border-radius: 0 0 10px 10px; + max-height: 300px; + overflow-y: auto; + } + + .search-item { + padding: 8px 16px; + font-size: 13px; + font-family: monospace; + color: #cdd6f4; + cursor: pointer; + } + + .search-item:hover { background: rgba(203, 166, 247, 0.1); } + + /* Help modal */ + #help-modal { + display: none; + position: fixed; + inset: 0; + z-index: 300; + align-items: center; + justify-content: center; + background: rgba(0,0,0,0.6); + backdrop-filter: blur(4px); + } + + .help-content { + background: #1e1e2e; + border: 1px solid #313244; + border-radius: 12px; + padding: 28px 36px; + min-width: 360px; + color: #cdd6f4; + } + + .help-content h2 { + font-size: 18px; + font-weight: 600; + margin-bottom: 16px; + color: #cba6f7; + } + + .help-content table { width: 100%; border-collapse: collapse; } + + .help-content td { + padding: 5px 0; + font-size: 13px; + } + + .help-content td:first-child { + font-family: monospace; + color: #89b4fa; + padding-right: 20px; + white-space: nowrap; + } + + .help-content td:last-child { color: #a6adc8; } + + .help-close { + margin-top: 16px; + text-align: right; + font-size: 12px; + color: #585b70; + } + /* History */ #history { display: none; @@ -279,8 +414,34 @@
+
+
- LMB drag — rotate  |  RMB drag — pan  |  scroll — zoom  |  double-click — focus file + WASD move · Q/E rotate · Z/C zoom
+ Space file view / overview · Esc reset
+ / search · dbl-click focus · scroll pan +
+ +
+ +
+
+ +
+
+

Keyboard Shortcuts

+ + + + + + + + + +
W A S DPan (move on XZ plane)
Q / ERotate view left / right
Z / CZoom in / out
SpaceFile overview → project overview
EscReset to project overview
/ or Ctrl+FSearch files
?Toggle this help
Double-clickFocus on file
+
Press ? or Esc to close
+
diff --git a/web/package-lock.json b/web/package-lock.json index afe9c33..8d927f7 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -9,7 +9,9 @@ "version": "0.1.0", "dependencies": { "d3-hierarchy": "^3.1.2", - "three": "^0.170.0" + "highlight.js": "^11.11.1", + "three": "^0.170.0", + "troika-three-text": "^0.52.4" }, "devDependencies": { "vite": "^6.0.0" @@ -762,6 +764,14 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, "node_modules/d3-hierarchy": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/d3-hierarchy/-/d3-hierarchy-3.1.2.tgz", @@ -842,6 +852,14 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/highlight.js": { + "version": "11.11.1", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", + "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", + "engines": { + "node": ">=12.0.0" + } + }, "node_modules/nanoid": { "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", @@ -906,6 +924,14 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rollup": { "version": "4.60.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", @@ -980,6 +1006,33 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/troika-three-text": { + "version": "0.52.4", + "resolved": "https://registry.npmjs.org/troika-three-text/-/troika-three-text-0.52.4.tgz", + "integrity": "sha512-V50EwcYGruV5rUZ9F4aNsrytGdKcXKALjEtQXIOBfhVoZU9VAqZNIoGQ3TMiooVqFAbR1w15T+f+8gkzoFzawg==", + "dependencies": { + "bidi-js": "^1.0.2", + "troika-three-utils": "^0.52.4", + "troika-worker-utils": "^0.52.0", + "webgl-sdf-generator": "1.1.1" + }, + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-three-utils": { + "version": "0.52.4", + "resolved": "https://registry.npmjs.org/troika-three-utils/-/troika-three-utils-0.52.4.tgz", + "integrity": "sha512-NORAStSVa/BDiG52Mfudk4j1FG4jC4ILutB3foPnfGbOeIs9+G5vZLa0pnmnaftZUGm4UwSoqEpWdqvC7zms3A==", + "peerDependencies": { + "three": ">=0.125.0" + } + }, + "node_modules/troika-worker-utils": { + "version": "0.52.0", + "resolved": "https://registry.npmjs.org/troika-worker-utils/-/troika-worker-utils-0.52.0.tgz", + "integrity": "sha512-W1CpvTHykaPH5brv5VHLfQo9D1OYuo0cSBEUQFFT/nBUzM8iD6Lq2/tgG/f1OelbAS1WtaTPQzE5uM49egnngw==" + }, "node_modules/vite": { "version": "6.4.2", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", @@ -1053,6 +1106,11 @@ "optional": true } } + }, + "node_modules/webgl-sdf-generator": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-sdf-generator/-/webgl-sdf-generator-1.1.1.tgz", + "integrity": "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA==" } } } diff --git a/web/package.json b/web/package.json index 06cd957..40239f6 100644 --- a/web/package.json +++ b/web/package.json @@ -9,7 +9,9 @@ }, "dependencies": { "d3-hierarchy": "^3.1.2", - "three": "^0.170.0" + "highlight.js": "^11.11.1", + "three": "^0.170.0", + "troika-three-text": "^0.52.4" }, "devDependencies": { "vite": "^6.0.0" diff --git a/web/src/app.js b/web/src/app.js index 6864fdb..a0057c7 100644 --- a/web/src/app.js +++ b/web/src/app.js @@ -31,7 +31,7 @@ function showError(msg) { alert(msg); } -async function visualize(tree) { +async function visualize(tree, repoName) { showLoading("Building layout..."); // Wait for fonts to load so canvas renders them correctly @@ -49,8 +49,9 @@ async function visualize(tree) { await new Promise((r) => setTimeout(r, 50)); showVisualization(); - const renderer = new RepoRenderer(viewport); - renderer.load(leaves, totalWidth, totalHeight); + document.getElementById("osd-info").classList.add("active"); + const renderer = new RepoRenderer(viewport, repoName || tree.name); + await renderer.load(leaves, totalWidth, totalHeight); } // --- History --- @@ -85,7 +86,7 @@ async function loadCachedRepo(key, name) { const res = await fetch(`/api/repos/${key}`); if (!res.ok) throw new Error("Cache expired"); const tree = await res.json(); - await visualize(tree); + await visualize(tree, name); } catch (err) { showError(err.message); } diff --git a/web/src/highlight.js b/web/src/highlight.js new file mode 100644 index 0000000..facb670 --- /dev/null +++ b/web/src/highlight.js @@ -0,0 +1,191 @@ +/** + * Syntax highlighting via highlight.js → troika colorRanges. + * Returns an object { charIndex: 0xRRGGBB } for troika Text.colorRanges. + */ + +import hljs from "highlight.js/lib/core"; + +// Register common languages (keep bundle small — add more as needed) +import javascript from "highlight.js/lib/languages/javascript"; +import typescript from "highlight.js/lib/languages/typescript"; +import python from "highlight.js/lib/languages/python"; +import rust from "highlight.js/lib/languages/rust"; +import go from "highlight.js/lib/languages/go"; +import java from "highlight.js/lib/languages/java"; +import cpp from "highlight.js/lib/languages/cpp"; +import c from "highlight.js/lib/languages/c"; +import css from "highlight.js/lib/languages/css"; +import xml from "highlight.js/lib/languages/xml"; +import json from "highlight.js/lib/languages/json"; +import yaml from "highlight.js/lib/languages/yaml"; +import bash from "highlight.js/lib/languages/bash"; +import sql from "highlight.js/lib/languages/sql"; +import ruby from "highlight.js/lib/languages/ruby"; +import php from "highlight.js/lib/languages/php"; +import swift from "highlight.js/lib/languages/swift"; +import kotlin from "highlight.js/lib/languages/kotlin"; +import scala from "highlight.js/lib/languages/scala"; +import lua from "highlight.js/lib/languages/lua"; +import markdown from "highlight.js/lib/languages/markdown"; + +hljs.registerLanguage("javascript", javascript); +hljs.registerLanguage("typescript", typescript); +hljs.registerLanguage("python", python); +hljs.registerLanguage("rust", rust); +hljs.registerLanguage("go", go); +hljs.registerLanguage("java", java); +hljs.registerLanguage("cpp", cpp); +hljs.registerLanguage("c", c); +hljs.registerLanguage("css", css); +hljs.registerLanguage("xml", xml); +hljs.registerLanguage("json", json); +hljs.registerLanguage("yaml", yaml); +hljs.registerLanguage("bash", bash); +hljs.registerLanguage("sql", sql); +hljs.registerLanguage("ruby", ruby); +hljs.registerLanguage("php", php); +hljs.registerLanguage("swift", swift); +hljs.registerLanguage("kotlin", kotlin); +hljs.registerLanguage("scala", scala); +hljs.registerLanguage("lua", lua); +hljs.registerLanguage("markdown", markdown); + +// Catppuccin Mocha-inspired color scheme +const TOKEN_COLORS = { + keyword: 0xcba6f7, // mauve + built_in: 0xf38ba8, // red + type: 0xf9e2af, // yellow + literal: 0xfab387, // peach + number: 0xfab387, + string: 0xa6e3a1, // green + regexp: 0xa6e3a1, + title: 0x89b4fa, // blue (function names, classes) + "title.class_": 0xf9e2af, + "title.function_": 0x89b4fa, + params: 0xcdd6f4, // text (default) + comment: 0x6c7086, // overlay0 + doctag: 0x6c7086, + meta: 0xf2cdcd, // flamingo + attr: 0xf9e2af, + attribute: 0xf9e2af, + name: 0x89b4fa, + selector: 0xa6e3a1, + tag: 0xf38ba8, + variable: 0xcdd6f4, + operator: 0x94e2d5, // teal + punctuation:0x9399b2, // overlay2 + property: 0x89dceb, // sapphire + symbol: 0xf5c2e7, // pink + addition: 0xa6e3a1, + deletion: 0xf38ba8, + section: 0x89b4fa, + bullet: 0x94e2d5, + emphasis: 0xcdd6f4, + strong: 0xcdd6f4, + formula: 0x94e2d5, + link: 0x89b4fa, + quote: 0xa6e3a1, + subst: 0xcdd6f4, + "template-variable": 0xcdd6f4, +}; + +const DEFAULT_COLOR = 0xcdd6f4; // catppuccin text + +// Map file extension to hljs language name +const EXT_TO_LANG = { + js: "javascript", jsx: "javascript", mjs: "javascript", cjs: "javascript", + ts: "typescript", tsx: "typescript", + py: "python", pyw: "python", + rs: "rust", + go: "go", + java: "java", + c: "c", h: "c", + cpp: "cpp", hpp: "cpp", cc: "cpp", + css: "css", scss: "css", less: "css", + html: "xml", htm: "xml", xml: "xml", svg: "xml", vue: "xml", + json: "json", + yaml: "yaml", yml: "yaml", + sh: "bash", bash: "bash", zsh: "bash", + sql: "sql", + rb: "ruby", + php: "php", + swift: "swift", + kt: "kotlin", + scala: "scala", + lua: "lua", + md: "markdown", +}; + +function getLanguage(filename) { + const dot = filename.lastIndexOf("."); + if (dot === -1) return null; + const ext = filename.substring(dot + 1).toLowerCase(); + return EXT_TO_LANG[ext] || null; +} + +function resolveColor(classes) { + // classes is like "hljs-keyword" or "hljs-title hljs-function_" + // Try most specific first + for (const cls of classes) { + const name = cls.replace("hljs-", ""); + if (TOKEN_COLORS[name] != null) return TOKEN_COLORS[name]; + } + // Try compound: "title.function_" + if (classes.length > 1) { + const compound = classes.map((c) => c.replace("hljs-", "")).join("."); + if (TOKEN_COLORS[compound] != null) return TOKEN_COLORS[compound]; + } + return null; +} + +/** + * Walk the hljs emitter tree and collect { charIndex: color } ranges. + */ +function walkTree(node, colorRanges, pos) { + if (typeof node === "string") { + return pos + node.length; + } + + if (node.children) { + const classes = node.scope ? node.scope.split(".").map((s) => `hljs-${s}`) : []; + const color = resolveColor(classes); + + if (color != null) { + colorRanges[pos] = color; + } + + let cursor = pos; + for (const child of node.children) { + cursor = walkTree(child, colorRanges, cursor); + } + + // Reset to default after this scope + if (color != null) { + colorRanges[cursor] = DEFAULT_COLOR; + } + + return cursor; + } + + return pos; +} + +/** + * Compute colorRanges for troika Text from source code. + * @param {string} content - source code + * @param {string} filename - for language detection + * @returns {object|null} colorRanges object, or null if no highlighting available + */ +export function computeColorRanges(content, filename) { + const lang = getLanguage(filename); + if (!lang) return null; + + try { + const result = hljs.highlight(content, { language: lang, ignoreIllegals: true }); + const colorRanges = { 0: DEFAULT_COLOR }; + walkTree(result._emitter.rootNode, colorRanges, 0); + return colorRanges; + } catch { + return null; + } +} diff --git a/web/src/renderer.js b/web/src/renderer.js index e085168..c562d7b 100644 --- a/web/src/renderer.js +++ b/web/src/renderer.js @@ -1,177 +1,75 @@ /** - * 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 + * 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"; -// Color palette by file extension +// ---------- 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, + ".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(filename) { - const dot = filename.lastIndexOf("."); - if (dot === -1) return DEFAULT_COLOR; - const ext = filename.substring(dot).toLowerCase(); - return EXT_COLORS[ext] ?? DEFAULT_COLOR; +function getColor(fn) { + const i = fn.lastIndexOf("."); + return i < 0 ? DEFAULT_COLOR : (EXT_COLORS[fn.substring(i).toLowerCase()] ?? 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)); - } - } +/** 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)); } - - 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; + return lines.join("\n"); } -/** - * 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 - */ - +// ---------- renderer ---------- export class RepoRenderer { - constructor(container) { + constructor(container, repoName) { this.container = container; + this.repoName = repoName || "repo"; 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 = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 50000); this.camera.position.set(0, 200, 200); this.camera.lookAt(0, 0, 0); @@ -180,28 +78,54 @@ export class RepoRenderer { 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); + 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() { - 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); + 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 }; - // Google Maps-style touch: 1 finger pan, 2 finger pinch zoom + rotate - this.controls.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() { @@ -210,164 +134,298 @@ export class RepoRenderer { 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) - ); - } + if (this.hoveredTile) this.flyToTile(this.hoveredTile); }); } - _animateTo(position, target, duration = 800) { - const startPos = this.camera.position.clone(); - const startTarget = this.controls.target.clone(); - const startTime = performance.now(); + _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 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); - + 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); } - /** - * Load layout data and create meshes. - */ - load(leaves, totalWidth, totalHeight) { - // Center the layout around origin - const offsetX = -totalWidth / 2; - const offsetZ = -totalHeight / 2; + _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; - // 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 + // --- 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); - this.scene.add(mesh); + // --- Label (always visible, cheap — one per file) --- + const label = new Text(); + label.text = leaf.name; + label.fontSize = Math.min(leaf.w, leaf.h) * 0.15; + 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(); - // Adjust leaf coords to world space for camera targeting - leaf.x += offsetX; - leaf.z += offsetZ; + // Pre-compute syntax highlight ranges (cheap, no GPU) + const colorRanges = computeColorRanges(leaf.content, leaf.name); this.tiles.push({ - mesh, - data: leaf, - codeTexture: null, - blockTexture: blockTex, - showingCode: false, - color, + bgMesh, label, darkMat, colorRanges, + codeMesh: null, watermark: null, darkMesh: null, + data: leaf, showingCode: false, color, dist: Infinity }); + this.bgMeshes.push(bgMesh); } - // 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.initialCamPos = this.camera.position.clone(); + this.initialCamTarget = this.controls.target.clone(); this.tooltip = document.getElementById("tooltip"); } - _updateLOD() { - const camPos = this.camera.position; + // -------- lazy code/watermark creation -------- + _showCode(tile) { + const d = tile.data; - 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; - } + // 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 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); + 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(); - this._updateLOD(); + + // 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); } }