diff --git a/README.md b/README.md index 6c79fe2..fde92e4 100644 --- a/README.md +++ b/README.md @@ -58,18 +58,79 @@ See [`bench/`](bench/) for the token-savings methodology and an A/B protocol. ## Run the viewer -The viewer is a local-first Next.js app. It indexes a repo with the same engine -and renders the **Atlas** and **City** views. +Once `openvisio` is installed, `view` indexes a repo and opens the bundled +**Atlas** and **City** views in your browser — zero install, served from +`127.0.0.1`: ```bash -npm run build # build the engine + CLI first (the viewer indexes with it) -cd ui -npm install -npm run dev # http://localhost:3000 +openvisio view # index the current repo and open the viewer +openvisio view ../other # …or any other local repo ``` -Point it at a local folder or a Git URL; everything is indexed and rendered on -your machine. +The viewer ships in the `openvisio-viewer` package: the same React/Three.js +Atlas + City views, as a self-contained static bundle. Toggle between them, click +to focus a file, and re-index any other local repo from the path box. Nothing +leaves your machine. + +**Watch your agent think.** `view` defaults to the spotlight port (7077), so it +doubles as the live-highlight hub: leave it running, point your agent at the repo +with `openvisio mcp . --spotlight`, and each tool call focuses the file it's +looking at — in real time. + +From a clone, build the workspace first (`npm run build` builds the engine, the +viewer, and the CLI), then `node mcp/dist/cli.js view .`. + +--- + +## Languages + +OpenVisio parses these into symbols and import/call edges (tree-sitter grammars). +Any other text file is still scanned as a graph node — templates (Twig, Blade), +Markdown, and EDA/hardware files (KiCad, Gerber) get a language label without +parsed symbols, so nothing in the repo is invisible. + +| Language | Extensions | +| ------------------ | ----------------------------------- | +| TypeScript | `.ts`, `.mts`, `.cts` | +| TSX | `.tsx` | +| JavaScript | `.js`, `.jsx`, `.mjs`, `.cjs` | +| Python | `.py`, `.pyi` | +| Go | `.go` | +| Rust | `.rs` | +| Java | `.java` | +| C | `.c`, `.h` | +| C++ | `.cpp`, `.cc`, `.cxx`, `.hpp`, `.hh`| +| C# | `.cs` | +| Kotlin | `.kt`, `.kts` | +| Ruby | `.rb` | +| PHP | `.php` | +| Swift | `.swift` (disabled by default) | +| Scala | `.scala` | +| Dart | `.dart` | +| Zig | `.zig` | +| Lua | `.lua` | +| R | `.r`, `.R` | +| Elixir | `.ex`, `.exs` | +| Elm | `.elm` | +| OCaml | `.ml`, `.mli` | +| ReScript | `.res` | +| Solidity | `.sol` | +| TLA+ | `.tla` | +| Objective-C | `.m`, `.mm` | +| Bash | `.sh`, `.bash` | +| Vue | `.vue` | +| HTML | `.html`, `.htm` | +| CSS | `.css` | +| JSON | `.json` | +| YAML | `.yaml`, `.yml` | +| TOML | `.toml` | +| Embedded Template | `.erb`, `.ejs` | +| SystemRDL | `.rdl` | +| QL | `.ql` | +| Emacs Lisp | `.el` | + +> Swift's grammar is heavy enough to crash V8's WASM compiler on some machines, +> so it's off by default — enable it with `OPENVISIO_ENABLE_GRAMMARS=swift`. --- @@ -79,7 +140,8 @@ your machine. |------|------------| | [`core/`](core/) | `@openvisio/core` — the deterministic code-graph engine (tree-sitter parse, import resolution, PageRank, token-budgeted skeletons). | | [`mcp/`](mcp/) | `openvisio` — the published MCP server + CLI. Bundles `core` into a single self-contained binary. | -| [`ui/`](ui/) | The local-first viewer (Atlas + City). | +| [`viewer/`](viewer/) | `openvisio-viewer` — the bundled Atlas + City app that `openvisio view` serves (React + Three.js, built to a static bundle). | +| [`ui/`](ui/) | Full Next.js web app (Atlas + City + AI narrator). | | [`bench/`](bench/) | Token-savings estimator + A/B measurement protocol. | | [`docs/`](docs/) | Engine, graph, and MCP integration notes. | diff --git a/core/src/parse/grammars/dart.ts b/core/src/parse/grammars/dart.ts index 949c26d..704e663 100644 --- a/core/src/parse/grammars/dart.ts +++ b/core/src/parse/grammars/dart.ts @@ -4,8 +4,12 @@ import type { GrammarConfig } from './index.js' const posix = path.posix const DART_SYMBOLS = ` -(class_definition (identifier) @name) @def.class -(function_signature (identifier) @name) @def.function +(class_definition name: (identifier) @name) @def.class +(function_signature name: (identifier) @name) @def.function +(getter_signature name: (identifier) @name) @def.function +(setter_signature name: (identifier) @name) @def.function +(enum_declaration name: (identifier) @name) @def.class +(mixin_declaration (identifier) @name) @def.class ` const DART_IMPORTS = ` (library_import (import_specification (configurable_uri (uri (string_literal) @source)))) diff --git a/core/src/parse/grammars/r.ts b/core/src/parse/grammars/r.ts index 50c3eec..64b5e2d 100644 --- a/core/src/parse/grammars/r.ts +++ b/core/src/parse/grammars/r.ts @@ -1,11 +1,11 @@ import type { GrammarConfig } from './index.js' const R_SYMBOLS = ` -(function_definition name: (identifier) @name) @def.function -(assignment name: (identifier) @name) @def.const +(binary_operator lhs: (identifier) @name rhs: (function_definition) @def) @def.function ` const R_IMPORTS = ` -(call function: (identifier) @fn arguments: (arguments (string) @source)) +(call function: (identifier) @fn arguments: (arguments (argument (identifier) @source))) +(call function: (identifier) @fn arguments: (arguments (argument (string) @source))) ` const R_CALLS = ` (call function: (identifier) @callee) @@ -15,12 +15,12 @@ export const rLanguage: GrammarConfig = { symbolQuery: R_SYMBOLS, importQuery: R_IMPORTS, callQuery: R_CALLS, - keep: (def) => def.parent?.type === 'program' || def.parent?.type === 'brace_list', + keep: (_def, _name) => true, exported: () => true, importSpecifier: (n) => { const s = n.text if (s.length >= 2 && (s[0] === '"' || s[0] === "'") && s[s.length - 1] === s[0]) return s.slice(1, -1) return s }, - resolveImport: () => null, // R source/library resolution is limited + resolveImport: () => null, } diff --git a/core/wasm/tree-sitter-r.wasm b/core/wasm/tree-sitter-r.wasm new file mode 100644 index 0000000..4ce49a9 Binary files /dev/null and b/core/wasm/tree-sitter-r.wasm differ diff --git a/mcp/README.md b/mcp/README.md index e83bc5a..823ee6e 100644 --- a/mcp/README.md +++ b/mcp/README.md @@ -124,14 +124,15 @@ openvisio export [path] [--out=.openvisio/graph.json] # emit the graph - `--task` personalizes the skeleton ranking toward a task description. - `--spotlight` exposes a local SSE channel so an open OpenVisio viewer lights up the files the agent is querying. -- `view` indexes the repo and serves a self-contained, dependency-free graph - viewer (bundled in the package — nothing to install) on `127.0.0.1`, then opens - your browser. It draws the same deterministic graph the MCP serves: files - colored by language, import edges, pan/zoom, search, and a per-language - breakdown. The path box re-indexes any other local repo. `--no-open` just - prints the URL. It defaults to the spotlight port (7077) and acts as the - highlight hub: with `view` running, start your agent with - `openvisio mcp . --spotlight` and its tool calls pulse the graph live. +- `view` indexes the repo and serves the OpenVisio **Atlas + City** views (the + same React/Three.js views from the app, shipped in the `openvisio-viewer` + package) on `127.0.0.1`, then opens your browser. City is a 3D treemap + (districts = folders, buildings = files, sized by LOC, colored by language); + Atlas is a file/symbol constellation linked by imports, definitions, and calls. + Toggle between them, click to focus, and re-index any other local repo from the + path box. `--no-open` just prints the URL. It defaults to the spotlight port + (7077) and acts as the highlight hub: with `view` running, start your agent + with `openvisio mcp . --spotlight` and its tool calls focus the graph live. --- diff --git a/mcp/build.mjs b/mcp/build.mjs index e78263b..1d57c27 100644 --- a/mcp/build.mjs +++ b/mcp/build.mjs @@ -6,24 +6,20 @@ // // Requires @openvisio/core to be built first (root: `npm run build`). -import { cpSync, rmSync } from 'node:fs' +import { rmSync } from 'node:fs' import { build } from 'esbuild' // Clean: the bundle is the only artifact; stale per-file tsc output must not // ride along into the published tarball. rmSync('dist', { recursive: true, force: true }) -// Ship the static viewer UI (served by `openvisio view`) beside the bundle so it -// resolves at dist/viewer relative to the compiled cli.js at runtime. -cpSync('viewer', 'dist/viewer', { recursive: true }) - const common = { bundle: true, platform: 'node', format: 'esm', target: 'node18', sourcemap: false, - external: ['@modelcontextprotocol/sdk', 'zod', 'web-tree-sitter', 'tree-sitter-wasms', 'lmdb'], + external: ['@modelcontextprotocol/sdk', 'zod', 'web-tree-sitter', 'tree-sitter-wasms', 'lmdb', 'openvisio-viewer'], logLevel: 'info', } diff --git a/mcp/package.json b/mcp/package.json index b280d28..a2931d2 100644 --- a/mcp/package.json +++ b/mcp/package.json @@ -1,6 +1,6 @@ { "name": "openvisio", - "version": "0.1.4", + "version": "0.1.5", "description": "See any codebase as a graph. MCP server + CLI that serves coding agents (Claude Code, Codex, Cursor) a token-cheap, ranked, graph-native query surface over a local repo — agents query structure instead of crawling files. Local-first, read-only, no network.", "type": "module", "license": "MIT", @@ -45,6 +45,7 @@ "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "lmdb": "^3.2.6", + "openvisio-viewer": "^0.1.0", "tree-sitter-wasms": "^0.1.13", "web-tree-sitter": "~0.25.10", "zod": "^4.4.3" diff --git a/mcp/src/server.ts b/mcp/src/server.ts index 14e91ee..de18d9e 100644 --- a/mcp/src/server.ts +++ b/mcp/src/server.ts @@ -89,7 +89,7 @@ export async function serveMcp(opts: ServeOptions): Promise { const badRoot = resolvedRoot === path.parse(resolvedRoot).root || resolvedRoot === path.resolve(os.homedir()) const server = new McpServer( - { name: 'openvisio', version: '0.1.3' }, + { name: 'openvisio', version: '0.1.5' }, { instructions: 'MANDATORY workflow for this repository. Before reading, grepping, globbing, ' + diff --git a/mcp/src/viewer.ts b/mcp/src/viewer.ts index fd48961..764c15d 100644 --- a/mcp/src/viewer.ts +++ b/mcp/src/viewer.ts @@ -1,11 +1,13 @@ // `openvisio view [repo]` — the local, open-source graph viewer. Builds the // deterministic graph with the SAME engine the MCP serves, then hosts a tiny -// localhost server that pairs the bundled static UI (viewer/) with the existing -// spotlight HTTP surface: GET /api/graph?path= indexes on demand, and the -// SSE stream is mounted too, so a running `openvisio mcp --spotlight` session on -// the same port lights up the map live. Local-first: binds 127.0.0.1 only. +// localhost server that pairs the bundled Atlas + City UI (the openvisio-viewer +// package) with the existing spotlight HTTP surface: GET /api/graph?path= +// indexes on demand, and the SSE stream is mounted too, so a running +// `openvisio mcp --spotlight` session on the same port lights up the map live. +// Local-first: binds 127.0.0.1 only. import { spawn } from 'node:child_process' +import { createRequire } from 'node:module' import * as fs from 'node:fs' import * as path from 'node:path' import { fileURLToPath } from 'node:url' @@ -23,16 +25,29 @@ export interface ViewerOptions { } /** - * Locate the bundled viewer assets. When running from the published bundle this - * is dist/viewer (copied beside cli.js by build.mjs); under `tsx src/cli.ts` it - * is the source mcp/viewer dir one level up. Falls back to the dist guess. + * Locate the built `openvisio-viewer` assets (the Atlas + City React/Three app). + * Primary path: resolve the installed package via node's resolver — works both + * when openvisio depends on it (node_modules) and in the workspace. Falls back to + * a few relative guesses for source/dev layouts. Returns null if it isn't built. */ -function resolveViewerDir(): string { +function resolveViewerDir(): string | null { const here = path.dirname(fileURLToPath(import.meta.url)) - for (const cand of [path.join(here, 'viewer'), path.join(here, '..', 'viewer')]) { + const candidates: string[] = [] + try { + const req = createRequire(import.meta.url) + candidates.push(path.join(path.dirname(req.resolve('openvisio-viewer/package.json')), 'dist')) + } catch { + // not resolvable (unbuilt workspace, or odd install) — fall through to guesses + } + candidates.push( + path.join(here, '..', '..', 'viewer', 'dist'), // mcp/dist → repo/viewer/dist + path.join(here, '..', '..', '..', 'viewer', 'dist'), + path.join(here, 'viewer'), // legacy bundled copy + ) + for (const cand of candidates) { if (fs.existsSync(path.join(cand, 'index.html'))) return cand } - return path.join(here, 'viewer') + return null } /** Open `url` in the system default browser (best-effort, never throws). */ @@ -60,6 +75,15 @@ function isAddrInUse(err: unknown): boolean { export async function serveViewer(opts: ViewerOptions): Promise { const root = path.resolve(opts.rootPath) const viewerDir = resolveViewerDir() + if (!viewerDir) { + process.stderr.write( + 'openvisio view: viewer assets not found. The `openvisio-viewer` package is missing or unbuilt.\n' + + ' • installed globally: reinstall with `npm i -g openvisio@latest`\n' + + ' • from the repo: run `npm run build -w openvisio-viewer` first\n', + ) + process.exitCode = 1 + return + } // On-demand indexer: empty path (the UI's first load can omit it) falls back // to the repo the command was launched from. const onIndex = async (repoPath: string) => toExportPayload(await buildGraph(repoPath || root), Date.now()) diff --git a/mcp/viewer/app.js b/mcp/viewer/app.js deleted file mode 100644 index 26d8b57..0000000 --- a/mcp/viewer/app.js +++ /dev/null @@ -1,466 +0,0 @@ -// OpenVisio local viewer — vanilla JS, Canvas 2D, zero dependencies. Fetches the -// deterministic graph from the local server (GET /api/graph?path=) and -// draws files at their precomputed layout positions, colored by language, with -// import edges. Pan/zoom, search, language filter, and a selection panel. This -// renders the exact same graph the MCP serves to coding agents. - -'use strict' - -// Language → color. Mirrors the engine's language set; anything unmapped is gray. -const LANG_COLORS = { - typescript: '#3178c6', javascript: '#f1e05a', python: '#3572A5', go: '#00ADD8', - rust: '#dea584', java: '#b07219', c: '#555555', cpp: '#f34b7d', csharp: '#178600', - kotlin: '#A97BFF', ruby: '#701516', php: '#4F5D95', swift: '#F05138', scala: '#c22d40', - dart: '#00B4AB', zig: '#ec915c', lua: '#000080', r: '#198CE7', elixir: '#6e4a7e', - elm: '#60B5CC', ocaml: '#3be133', solidity: '#AA6746', objc: '#438eff', bash: '#89e051', - vue: '#41b883', html: '#e34c26', css: '#563d7c', json: '#cbcb41', yaml: '#cb171e', - toml: '#9c4221', markdown: '#74a0c0', twig: '#9bbd5e', blade: '#f7523f', eda: '#ff8c00', - other: '#6e7681', -} -const colorFor = (lang) => LANG_COLORS[lang] || LANG_COLORS.other - -// ── DOM ── -const canvas = document.getElementById('graph') -const ctx = canvas.getContext('2d') -const tooltip = document.getElementById('tooltip') -const overlay = document.getElementById('overlay') -const statsEl = document.getElementById('stats') -const legendEl = document.getElementById('legend') -const repoNameEl = document.getElementById('repo-name') -const detailsPanel = document.getElementById('details-panel') -const detailsEl = document.getElementById('details') -const searchEl = document.getElementById('search') -const edgesToggle = document.getElementById('edges-toggle') -const pathInput = document.getElementById('path-input') -const indexForm = document.getElementById('index-form') -const indexBtn = document.getElementById('index-btn') - -// ── State ── -const state = { - nodes: [], // {id, path, lang, loc, x, y, r} (x/y in layout space) - edges: [], // {a: nodeIndex, b: nodeIndex} (import edges, file-level) - byId: new Map(), // file id → node - byPath: new Map(), // repo-relative path → node (for spotlight lookups) - inDeg: new Map(), // file id → imported-by count - outDeg: new Map(), // file id → imports count - spotlight: { focus: new Set(), edges: new Set(), tool: '', active: false }, // live agent highlight - view: { scale: 1, tx: 0, ty: 0 }, - selected: null, // node or null - hover: null, // node or null - query: '', - hiddenLangs: new Set(), - showEdges: true, - dpr: Math.min(window.devicePixelRatio || 1, 2), -} - -// ── Sizing ── -function resize() { - const wrap = canvas.parentElement - const w = wrap.clientWidth - const h = wrap.clientHeight - canvas.width = Math.round(w * state.dpr) - canvas.height = Math.round(h * state.dpr) - canvas.style.width = w + 'px' - canvas.style.height = h + 'px' - draw() -} -window.addEventListener('resize', resize) - -// ── Data load ── -async function indexRepo(repoPath) { - showOverlay('Indexing ' + repoPath + ' …') - indexBtn.disabled = true - try { - const res = await fetch('/api/graph?path=' + encodeURIComponent(repoPath)) - const body = await res.json() - if (!res.ok) throw new Error(body && body.error ? body.error : 'index failed (' + res.status + ')') - ingest(body.graph) - hideOverlay() - } catch (err) { - showError(String(err && err.message ? err.message : err)) - } finally { - indexBtn.disabled = false - } -} - -// Turn the wire GraphResponse into render-ready nodes/edges. -function ingest(graph) { - const pos = new Map() - for (const n of graph.layout.nodes) pos.set(n.id, n) - state.byId.clear(); state.byPath.clear(); state.inDeg.clear(); state.outDeg.clear() - state.nodes = [] - for (const f of graph.files) { - const p = pos.get(f.id) - if (!p) continue // no layout position → skip (isolated/filtered upstream) - const node = { id: f.id, path: f.path, lang: f.language, loc: f.loc, x: p.x, y: p.y, r: radiusFor(f.loc) } - state.nodes.push(node) - state.byId.set(f.id, node) - state.byPath.set(f.path, node) - } - state.edges = [] - for (const e of graph.edges) { - if (e.edge_kind !== 'import') continue // file map shows imports, not symbol calls - const a = state.byId.get(e.source_id) - const b = state.byId.get(e.target_id) - if (!a || !b) continue - state.edges.push({ a, b }) - state.outDeg.set(a.id, (state.outDeg.get(a.id) || 0) + 1) - state.inDeg.set(b.id, (state.inDeg.get(b.id) || 0) + 1) - } - state.selected = null - detailsPanel.hidden = true - - // Header + sidebar - const repo = graph.repo - repoNameEl.textContent = repo.name || repo.root_path - statsEl.innerHTML = - '' + repo.file_count + ' files · ' + state.edges.length + ' imports · ' + - repo.total_loc.toLocaleString() + ' loc' - buildLegend() - fitView() -} - -const radiusFor = (loc) => Math.max(2.5, Math.min(16, 2 + Math.sqrt(loc || 1) / 3)) - -// Per-language file + loc tallies, sorted by loc desc — same shape as the -// get_languages MCP tool, rendered as a clickable filter legend. -function buildLegend() { - const tally = new Map() - for (const n of state.nodes) { - const t = tally.get(n.lang) || { files: 0, loc: 0 } - t.files++; t.loc += n.loc; tally.set(n.lang, t) - } - const ranked = [...tally.entries()].sort((a, b) => b[1].loc - a[1].loc || b[1].files - a[1].files) - legendEl.innerHTML = '' - for (const [lang, t] of ranked) { - const li = document.createElement('li') - if (state.hiddenLangs.has(lang)) li.classList.add('off') - li.innerHTML = - '' + - '' + lang + '' + - '' + t.files + ' · ' + t.loc.toLocaleString() + '' - li.addEventListener('click', () => { - if (state.hiddenLangs.has(lang)) state.hiddenLangs.delete(lang) - else state.hiddenLangs.add(lang) - buildLegend(); draw() - }) - legendEl.appendChild(li) - } -} - -// ── View fitting ── -function fitView() { - if (state.nodes.length === 0) { draw(); return } - let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity - for (const n of state.nodes) { - if (n.x < minX) minX = n.x; if (n.x > maxX) maxX = n.x - if (n.y < minY) minY = n.y; if (n.y > maxY) maxY = n.y - } - const w = canvas.width, h = canvas.height - const pad = 40 * state.dpr - const gw = Math.max(1, maxX - minX), gh = Math.max(1, maxY - minY) - const scale = Math.min((w - pad * 2) / gw, (h - pad * 2) / gh) - state.fitScale = scale - state.view.scale = scale - state.view.tx = (w - scale * (minX + maxX)) / 2 - state.view.ty = (h - scale * (minY + maxY)) / 2 - draw() -} - -// Center the view on a node and select it, zooming in if we're at the far-out -// fit scale so the node + its neighbors are legible. -function focusNode(node) { - if (!node) return - state.selected = node - state.view.scale = Math.max(state.view.scale, (state.fitScale || state.view.scale) * 3) - state.view.tx = canvas.width / 2 - node.x * state.view.scale - state.view.ty = canvas.height / 2 - node.y * state.view.scale - renderDetails(node) - draw() -} - -// Best node match for a query: prefer an exact filename, else shortest path that -// contains it (the least-nested, most likely "the" file). -function bestMatch(q) { - q = q.toLowerCase() - let exact = null, contains = null - for (const n of state.nodes) { - if (state.hiddenLangs.has(n.lang)) continue - const p = n.path.toLowerCase() - const base = p.split('/').pop() - if (base === q || base === q.split('/').pop()) { if (!exact || n.path.length < exact.path.length) exact = n } - else if (p.includes(q)) { if (!contains || n.path.length < contains.path.length) contains = n } - } - return exact || contains -} - -// Layout-space → device-pixel screen coords. -const sx = (x) => x * state.view.scale + state.view.tx -const sy = (y) => y * state.view.scale + state.view.ty - -// ── Rendering ── -function isVisible(node) { - if (state.hiddenLangs.has(node.lang)) return false - if (state.query && !node.path.toLowerCase().includes(state.query)) return false - return true -} - -const EDGE_KEY = (a, b) => a + '' + b - -function draw() { - ctx.setTransform(1, 0, 0, 1, 0, 0) - ctx.clearRect(0, 0, canvas.width, canvas.height) - if (state.nodes.length === 0) return - - // The live agent spotlight takes visual precedence over manual selection while - // it's active (a tool call just landed); otherwise selection drives emphasis. - const spot = state.spotlight.active ? state.spotlight : null - const sel = spot ? null : state.selected - const neighbors = sel ? neighborSet(sel) : null - const emphasized = (n) => (spot ? spot.focus.has(n.id) : sel ? n === sel || (neighbors && neighbors.has(n.id)) : true) - const focusMode = Boolean(spot || sel) - - // Edges first (under nodes). Active edges (the agent's, or the selection's) - // brighten; the rest mute so local structure reads against the global graph. - if (state.showEdges) { - for (const e of state.edges) { - if (!isVisible(e.a) || !isVisible(e.b)) continue - const active = spot - ? spot.edges.has(EDGE_KEY(e.a.path, e.b.path)) || spot.edges.has(EDGE_KEY(e.b.path, e.a.path)) - : sel && (e.a === sel || e.b === sel) - ctx.lineWidth = (active ? 1.4 : 0.6) * state.dpr - ctx.strokeStyle = active - ? spot ? 'rgba(245,158,11,0.9)' : 'rgba(79,156,255,0.85)' - : focusMode ? 'rgba(120,140,165,0.06)' : 'rgba(120,140,165,0.28)' - ctx.beginPath() - ctx.moveTo(sx(e.a.x), sy(e.a.y)) - ctx.lineTo(sx(e.b.x), sy(e.b.y)) - ctx.stroke() - } - } - - // Nodes. - for (const n of state.nodes) { - const vis = isVisible(n) - const emph = emphasized(n) - const dim = !vis || (focusMode && !emph) - const spotFocus = spot && spot.focus.has(n.id) - const r = n.r * state.dpr * (spotFocus ? 1.7 : n === sel ? 1.6 : 1) - ctx.globalAlpha = dim ? 0.16 : 1 - ctx.fillStyle = colorFor(n.lang) - ctx.beginPath() - ctx.arc(sx(n.x), sy(n.y), r, 0, Math.PI * 2) - ctx.fill() - if (spotFocus || n === sel || n === state.hover) { - ctx.globalAlpha = 1 - ctx.lineWidth = (spotFocus ? 2 : 1.5) * state.dpr - ctx.strokeStyle = spotFocus ? '#f59e0b' : '#fff' - ctx.stroke() - } - } - ctx.globalAlpha = 1 - - // Labels: the agent's focus, the selection + its neighbors, and big nodes when - // zoomed in. - ctx.font = (11 * state.dpr) + 'px ui-monospace, monospace' - ctx.fillStyle = '#e6edf3' - ctx.textBaseline = 'middle' - for (const n of state.nodes) { - if (!isVisible(n)) continue - const labelled = - (spot && spot.focus.has(n.id)) || - n === sel || - (neighbors && neighbors.has(n.id)) || - (state.view.scale > 2.2 && n.r > 6) - if (!labelled) continue - const name = n.path.split('/').pop() - ctx.fillText(name, sx(n.x) + n.r * state.dpr + 4, sy(n.y)) - } -} - -function neighborSet(node) { - const set = new Set() - for (const e of state.edges) { - if (e.a === node) set.add(e.b.id) - else if (e.b === node) set.add(e.a.id) - } - return set -} - -// ── Hit testing ── -function nodeAt(px, py) { - // px/py are device pixels. Search nearest within its radius (+ slack). - let best = null, bestD = Infinity - for (const n of state.nodes) { - if (!isVisible(n)) continue - const dx = sx(n.x) - px, dy = sy(n.y) - py - const d = dx * dx + dy * dy - const rr = Math.pow(n.r * state.dpr + 4 * state.dpr, 2) - if (d <= rr && d < bestD) { best = n; bestD = d } - } - return best -} - -// ── Interaction ── -let dragging = false, dragMoved = false, lastX = 0, lastY = 0 -canvas.addEventListener('mousedown', (e) => { - dragging = true; dragMoved = false - lastX = e.clientX; lastY = e.clientY - canvas.classList.add('dragging') -}) -window.addEventListener('mouseup', () => { dragging = false; canvas.classList.remove('dragging') }) -window.addEventListener('mousemove', (e) => { - if (!dragging) return - const dx = (e.clientX - lastX) * state.dpr, dy = (e.clientY - lastY) * state.dpr - if (Math.abs(e.clientX - lastX) + Math.abs(e.clientY - lastY) > 3) dragMoved = true - state.view.tx += dx; state.view.ty += dy - lastX = e.clientX; lastY = e.clientY - draw() -}) - -canvas.addEventListener('mousemove', (e) => { - const rect = canvas.getBoundingClientRect() - const px = (e.clientX - rect.left) * state.dpr, py = (e.clientY - rect.top) * state.dpr - const hit = dragging ? null : nodeAt(px, py) - if (hit !== state.hover) { state.hover = hit; draw() } - if (hit) { - tooltip.hidden = false - tooltip.style.left = (e.clientX - rect.left + 14) + 'px' - tooltip.style.top = (e.clientY - rect.top + 14) + 'px' - tooltip.innerHTML = - '
' + hit.path + '
' + - '
' + hit.lang + ' · ' + hit.loc + ' loc · imports ' + - (state.outDeg.get(hit.id) || 0) + ' · imported by ' + (state.inDeg.get(hit.id) || 0) + '
' - } else { - tooltip.hidden = true - } -}) -canvas.addEventListener('mouseleave', () => { state.hover = null; tooltip.hidden = true; draw() }) - -canvas.addEventListener('click', (e) => { - if (dragMoved) return - const rect = canvas.getBoundingClientRect() - const px = (e.clientX - rect.left) * state.dpr, py = (e.clientY - rect.top) * state.dpr - const hit = nodeAt(px, py) - state.selected = hit - renderDetails(hit) - draw() -}) - -canvas.addEventListener('wheel', (e) => { - e.preventDefault() - const rect = canvas.getBoundingClientRect() - const px = (e.clientX - rect.left) * state.dpr, py = (e.clientY - rect.top) * state.dpr - const factor = Math.exp(-e.deltaY * 0.0015) - const next = Math.max(0.05, Math.min(40, state.view.scale * factor)) - const k = next / state.view.scale - // Zoom about the cursor: keep the layout point under the cursor fixed. - state.view.tx = px - k * (px - state.view.tx) - state.view.ty = py - k * (py - state.view.ty) - state.view.scale = next - draw() -}, { passive: false }) - -function renderDetails(node) { - if (!node) { detailsPanel.hidden = true; return } - detailsPanel.hidden = false - const imports = [] - const importedBy = [] - for (const e of state.edges) { - if (e.a === node) imports.push(e.b.path) - else if (e.b === node) importedBy.push(e.a.path) - } - const list = (label, arr) => { - if (arr.length === 0) return '' - const shown = arr.slice(0, 6).map((p) => '
' + p + '
').join('') - const more = arr.length > 6 ? '
… +' + (arr.length - 6) + ' more
' : '' - return '
' + label + ' (' + arr.length + ')' + shown + more + '
' - } - detailsEl.innerHTML = - '
' + node.path + '
' + - '
language' + node.lang + '
' + - '
lines' + node.loc + '
' + - list('imports', imports) + list('imported by', importedBy) -} - -// ── Controls ── -searchEl.addEventListener('input', () => { state.query = searchEl.value.trim().toLowerCase(); draw() }) -searchEl.addEventListener('keydown', (e) => { - if (e.key !== 'Enter') return - e.preventDefault() - const match = bestMatch(searchEl.value.trim()) - if (!match) return - // Jump to the file: clear the text filter so its neighbors stay visible, then - // select + center it. - searchEl.value = '' - state.query = '' - focusNode(match) -}) -edgesToggle.addEventListener('change', () => { state.showEdges = edgesToggle.checked; draw() }) -indexForm.addEventListener('submit', (e) => { - e.preventDefault() - const p = pathInput.value.trim() - if (!p) return - const url = new URL(location.href) - url.searchParams.set('path', p) - history.replaceState(null, '', url) - indexRepo(p) -}) - -// ── Overlay helpers ── -function showOverlay(msg) { overlay.hidden = false; overlay.classList.remove('error'); overlay.textContent = msg } -function hideOverlay() { overlay.hidden = true } -function showError(msg) { overlay.hidden = false; overlay.classList.add('error'); overlay.textContent = 'Error: ' + msg } - -// ── Live agent spotlight ── -// This page is served by the same process that hosts the spotlight hub, so we -// subscribe same-origin. When a coding agent runs `openvisio mcp --spotlight` -// against this port, each tool call broadcasts the files/edges it touched; we -// pulse them amber. Quiet when no agent is attached. -const agentDot = document.getElementById('agent-status') -let spotlightFade = null -function setAgent(stateName, tool) { - if (!agentDot) return - agentDot.className = 'agent ' + stateName - agentDot.textContent = stateName === 'live' ? '● ' + (tool || 'agent') : stateName === 'idle' ? '○ stream' : '' - agentDot.hidden = stateName === 'off' -} -function applySpotlight(data) { - // Ignore the bus's replayed "last event" if it's stale (avoids a flash of an - // old highlight when the page connects mid-session). - if (data.ts && Date.now() - data.ts > 15000) return - const focus = new Set() - for (const p of data.focus || []) { const n = state.byPath.get(p); if (n) focus.add(n.id) } - const edges = new Set() - for (const pair of data.edges || []) if (Array.isArray(pair) && pair.length === 2) edges.add(EDGE_KEY(pair[0], pair[1])) - state.spotlight = { focus, edges, tool: data.tool || 'agent', active: focus.size > 0 || edges.size > 0 } - setAgent('live', state.spotlight.tool) - draw() - if (spotlightFade) clearTimeout(spotlightFade) - spotlightFade = setTimeout(() => { state.spotlight.active = false; setAgent('idle'); draw() }, 5000) -} -function connectSpotlight() { - if (typeof EventSource === 'undefined') return - let es - try { es = new EventSource('/api/spotlight') } catch { return } - es.addEventListener('spotlight', (ev) => { - let data - try { data = JSON.parse(ev.data) } catch { return } - if (!data || data.v !== 1) return - if (data.kind && data.kind !== 'highlight') return // queued/consumed/answer aren't visualized here - applySpotlight(data) - }) - es.onopen = () => { if (!state.spotlight.active) setAgent('idle') } - es.onerror = () => setAgent('off') -} - -// ── Boot ── -resize() -connectSpotlight() -const initial = new URLSearchParams(location.search).get('path') -if (initial) { - pathInput.value = initial - indexRepo(initial) -} else { - showOverlay('Enter a local repo path above and press Index.') -} diff --git a/mcp/viewer/index.html b/mcp/viewer/index.html deleted file mode 100644 index 1cf83e7..0000000 --- a/mcp/viewer/index.html +++ /dev/null @@ -1,48 +0,0 @@ - - - - - - OpenVisio — local graph viewer - - - - -
-
OpenVisio · local viewer
-
- - -
-
- -
- -
- - -
- - -
Loading…
-
drag to pan · scroll to zoom · click a node
-
-
- - - - diff --git a/mcp/viewer/style.css b/mcp/viewer/style.css deleted file mode 100644 index d4feb8d..0000000 --- a/mcp/viewer/style.css +++ /dev/null @@ -1,135 +0,0 @@ -/* OpenVisio local viewer — self-contained, no framework. Dark "engineering" - palette, distinct from the marketing app's theme. */ -:root { - --bg: #0d1117; - --panel: #141b24; - --panel-2: #1b242f; - --line: #263240; - --text: #e6edf3; - --dim: #8b97a6; - --faint: #5a6675; - --accent: #4f9cff; - --accent-soft: rgba(79, 156, 255, 0.18); -} - -* { box-sizing: border-box; } -html, body { height: 100%; margin: 0; } -body { - font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - background: var(--bg); - color: var(--text); - display: flex; - flex-direction: column; - overflow: hidden; -} - -#topbar { - display: flex; - align-items: center; - gap: 16px; - padding: 10px 16px; - background: var(--panel); - border-bottom: 1px solid var(--line); - flex: 0 0 auto; -} -.brand { font-weight: 600; letter-spacing: 0.2px; } -.dim { color: var(--dim); font-weight: 400; } - -.path-box { display: flex; gap: 6px; flex: 1 1 auto; max-width: 640px; } -.path-box input { - flex: 1 1 auto; - background: var(--bg); - border: 1px solid var(--line); - color: var(--text); - border-radius: 6px; - padding: 6px 10px; - font: inherit; -} -.path-box input:focus { outline: none; border-color: var(--accent); } -.path-box button { - background: var(--accent); - color: #04101f; - border: none; - border-radius: 6px; - padding: 6px 14px; - font: inherit; - font-weight: 600; - cursor: pointer; -} -.path-box button:disabled { opacity: 0.5; cursor: default; } - -.stats { color: var(--dim); white-space: nowrap; font-size: 12px; } -.stats b { color: var(--text); font-weight: 600; } - -/* Live agent indicator: dim when the SSE stream is open but idle, amber + pulse - when a tool call just lit the graph. */ -.agent { font-size: 11px; white-space: nowrap; padding: 3px 8px; border-radius: 999px; border: 1px solid var(--line); } -.agent.idle { color: var(--faint); } -.agent.live { color: #f59e0b; border-color: rgba(245, 158, 11, 0.5); background: rgba(245, 158, 11, 0.1); animation: agentpulse 1.2s ease-in-out infinite; } -@keyframes agentpulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.55; } } - -#stage { flex: 1 1 auto; display: flex; min-height: 0; } - -#sidebar { - width: 280px; - flex: 0 0 auto; - background: var(--panel); - border-right: 1px solid var(--line); - padding: 14px; - overflow-y: auto; - display: flex; - flex-direction: column; - gap: 16px; -} -.panel { background: var(--panel-2); border: 1px solid var(--line); border-radius: 8px; padding: 12px; } -.panel-title { font-size: 11px; text-transform: uppercase; letter-spacing: 0.6px; color: var(--faint); margin-bottom: 10px; } -#repo-name { color: var(--text); text-transform: none; letter-spacing: 0; font-size: 13px; font-weight: 600; word-break: break-all; } - -#search { width: 100%; background: var(--bg); border: 1px solid var(--line); color: var(--text); border-radius: 6px; padding: 6px 9px; font: inherit; margin-bottom: 10px; } -#search:focus { outline: none; border-color: var(--accent); } -.toggle { display: flex; align-items: center; gap: 7px; color: var(--dim); cursor: pointer; user-select: none; } - -.legend { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 2px; } -.legend li { display: flex; align-items: center; gap: 8px; padding: 4px 6px; border-radius: 5px; cursor: pointer; } -.legend li:hover { background: var(--bg); } -.legend li.off { opacity: 0.4; } -.legend .swatch { width: 10px; height: 10px; border-radius: 2px; flex: 0 0 auto; } -.legend .lang { flex: 1 1 auto; } -.legend .count { color: var(--faint); font-size: 11px; } - -#details { color: var(--dim); font-size: 12px; } -#details .file-path { color: var(--text); word-break: break-all; margin-bottom: 8px; } -#details .row { display: flex; justify-content: space-between; padding: 2px 0; } -#details .row b { color: var(--text); font-weight: 600; } -#details .neighbors { margin-top: 8px; } -#details .neighbors div { color: var(--faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } - -#canvas-wrap { position: relative; flex: 1 1 auto; min-width: 0; } -#graph { display: block; width: 100%; height: 100%; cursor: grab; } -#graph.dragging { cursor: grabbing; } - -#tooltip { - position: absolute; - pointer-events: none; - background: rgba(8, 12, 18, 0.95); - border: 1px solid var(--line); - border-radius: 6px; - padding: 6px 9px; - font-size: 12px; - max-width: 320px; - z-index: 5; -} -#tooltip .t-path { color: var(--text); word-break: break-all; } -#tooltip .t-meta { color: var(--dim); margin-top: 2px; } - -.overlay { - position: absolute; inset: 0; - display: flex; align-items: center; justify-content: center; - background: rgba(13, 17, 23, 0.85); - color: var(--dim); - z-index: 8; -} -.overlay.error { color: #ff7b72; padding: 24px; text-align: center; } -.overlay[hidden] { display: none; } - -.hint { position: absolute; bottom: 10px; left: 12px; color: var(--faint); font-size: 11px; pointer-events: none; } diff --git a/package-lock.json b/package-lock.json index 7130bf7..90ddcfa 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,8 @@ "license": "MIT", "workspaces": [ "core", - "mcp" + "mcp", + "viewer" ], "devDependencies": { "tsx": "^4.19.2" @@ -33,12 +34,13 @@ }, "mcp": { "name": "openvisio", - "version": "0.1.2", + "version": "0.1.5", "hasInstallScript": true, "license": "MIT", "dependencies": { "@modelcontextprotocol/sdk": "^1.29.0", "lmdb": "^3.2.6", + "openvisio-viewer": "^0.1.0", "tree-sitter-wasms": "^0.1.13", "web-tree-sitter": "~0.25.10", "zod": "^4.4.3" @@ -99,6 +101,306 @@ "@esbuild/win32-x64": "0.25.12" } }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@dimforge/rapier3d-compat": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz", + "integrity": "sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.12", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", @@ -559,6 +861,56 @@ "hono": "^4" } }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@lmdb/lmdb-darwin-arm64": { "version": "3.5.6", "resolved": "https://registry.npmjs.org/@lmdb/lmdb-darwin-arm64/-/lmdb-darwin-arm64-3.5.6.tgz", @@ -650,6 +1002,13 @@ "win32" ] }, + "node_modules/@mediapipe/tasks-vision": { + "version": "0.10.17", + "resolved": "https://registry.npmjs.org/@mediapipe/tasks-vision/-/tasks-vision-0.10.17.tgz", + "integrity": "sha512-CZWV/q6TTe8ta61cZXjfnnHsfWIdFhms03M9T7Cnd5y2mdpylJM0rF1qRq+wsQVRMLz1OYPVEBU9ph2Bx8cxrg==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/@modelcontextprotocol/sdk": { "version": "1.29.0", "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", @@ -690,6 +1049,19 @@ } } }, + "node_modules/@monogrid/gainmap-js": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/@monogrid/gainmap-js/-/gainmap-js-3.4.0.tgz", + "integrity": "sha512-2Z0FATFHaoYJ8b+Y4y4Hgfn3FRFwuU5zRrk+9dFWp4uGAdHGqVEdP7HP+gLA3X469KXHmfupJaUbKo1b/aDKIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "promise-worker-transferable": "^1.0.4" + }, + "peerDependencies": { + "three": ">= 0.159.0" + } + }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.4.tgz", @@ -772,85 +1144,1024 @@ "resolved": "core", "link": true }, - "node_modules/@types/node": { - "version": "22.20.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", - "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", + "node_modules/@react-three/drei": { + "version": "10.7.7", + "resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.7.tgz", + "integrity": "sha512-ff+J5iloR0k4tC++QtD/j9u3w5fzfgFAWDtAGQah9pF2B1YgOq/5JxqY0/aVoQG5r3xSZz0cv5tk2YuBob4xEQ==", "dev": true, "license": "MIT", "dependencies": { - "undici-types": "~6.21.0" - } - }, - "node_modules/accepts": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", - "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", - "license": "MIT", - "dependencies": { - "mime-types": "^3.0.0", - "negotiator": "^1.0.0" + "@babel/runtime": "^7.26.0", + "@mediapipe/tasks-vision": "0.10.17", + "@monogrid/gainmap-js": "^3.0.6", + "@use-gesture/react": "^10.3.1", + "camera-controls": "^3.1.0", + "cross-env": "^7.0.3", + "detect-gpu": "^5.0.56", + "glsl-noise": "^0.0.0", + "hls.js": "^1.5.17", + "maath": "^0.10.8", + "meshline": "^3.3.1", + "stats-gl": "^2.2.8", + "stats.js": "^0.17.0", + "suspend-react": "^0.1.3", + "three-mesh-bvh": "^0.8.3", + "three-stdlib": "^2.35.6", + "troika-three-text": "^0.52.4", + "tunnel-rat": "^0.1.2", + "use-sync-external-store": "^1.4.0", + "utility-types": "^3.11.0", + "zustand": "^5.0.1" }, - "engines": { - "node": ">= 0.6" - } - }, - "node_modules/ajv": { - "version": "8.20.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", - "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", - "license": "MIT", - "dependencies": { - "fast-deep-equal": "^3.1.3", - "fast-uri": "^3.0.1", - "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2" + "peerDependencies": { + "@react-three/fiber": "^9.0.0", + "react": "^19", + "react-dom": "^19", + "three": ">=0.159" }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/epoberezkin" + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } } }, - "node_modules/ajv-formats": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", - "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "node_modules/@react-three/fiber": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.6.1.tgz", + "integrity": "sha512-zF0rsKcVYpcJwbFEnv2HkHX9cvOEgsfQo/X8lwmR2dn13S4qEQJXir9fxf5js2LQFoXqxOY7MDkOkYx2uZ4gSg==", + "dev": true, "license": "MIT", + "peer": true, "dependencies": { - "ajv": "^8.0.0" + "@babel/runtime": "^7.17.8", + "@types/webxr": "*", + "base64-js": "^1.5.1", + "buffer": "^6.0.3", + "its-fine": "^2.0.0", + "react-use-measure": "^2.1.7", + "scheduler": "^0.27.0", + "suspend-react": "^0.1.3", + "use-sync-external-store": "^1.4.0", + "zustand": "^5.0.3" }, "peerDependencies": { - "ajv": "^8.0.0" + "expo": ">=43.0", + "expo-asset": ">=8.4", + "expo-file-system": ">=11.0", + "expo-gl": ">=11.0", + "react": ">=19 <19.3", + "react-dom": ">=19 <19.3", + "react-native": ">=0.78", + "three": ">=0.156" }, "peerDependenciesMeta": { - "ajv": { + "expo": { + "optional": true + }, + "expo-asset": { + "optional": true + }, + "expo-file-system": { + "optional": true + }, + "expo-gl": { + "optional": true + }, + "react-dom": { + "optional": true + }, + "react-native": { "optional": true } } }, - "node_modules/body-parser": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", - "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", - "license": "MIT", - "dependencies": { - "bytes": "^3.1.2", - "content-type": "^2.0.0", - "debug": "^4.4.3", - "http-errors": "^2.0.1", - "iconv-lite": "^0.7.2", - "on-finished": "^2.4.1", - "qs": "^6.15.2", - "raw-body": "^3.0.2", - "type-is": "^2.1.0" - }, - "engines": { - "node": ">=18" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/express" - } + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", + "integrity": "sha512-6o7ZLZK+BeenkZCFNDXqpbjw9bD6nuWonvS/lwQJp7NoVVxm6p3qE7qQ5jGuBjiFsgvqjD8mZAU5oWxTmbOeOg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.2.tgz", + "integrity": "sha512-BaH7BllCACHoH1LguOU56UItGfUWjujlO65kS9LAodViaN4bwIKd7oeW/ZHJ/4ljr/7MIiENnNy3HJ0zXv8Zkw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.2.tgz", + "integrity": "sha512-v39RCCvj4He82I9sFmk+M1VZ0PLM9sfsLVikjfx2hYBNALhrrOR2D3JjQA6AhlaSOgcR+RzrKY7e1+bT6SUO/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.2.tgz", + "integrity": "sha512-yl0y2vq3S3lHeuXhEdss6TWfKW8vkujImO12tn4ZkG/4oghr09LvdYm2RElVjokTQiUvDUGXLGsYeLqUMCKpGA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.2.tgz", + "integrity": "sha512-tT4pvt4qXD+vEoezupCWi+a1F0vvDiksiHc+PxRlYTOH1I6/X4id9jPxTP+Fg+545euaFT1jJVs4CEdHZAU1vw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.2.tgz", + "integrity": "sha512-6nU5F2wCW+qvCBhTn1pdIU3bzsIoF7EUwsCDRxilWGprQR6yd508YnH9+OKFCwpfS8pjZqDUmnCAr7exax0XCg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.2.tgz", + "integrity": "sha512-n1GJHPOvpIfhi3TmrCeh6S6URt9BFCt0KQE3qvexyGCTAKpR4Lg+eWvNZEqu7epxwus/8ElT3hacYEucm49SZg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.2.tgz", + "integrity": "sha512-JqgflS8wEB+UXV/vS1RpRbifGBeN4D5lz8D8oOFbFZw4vedvdOgCFAjfBmIMdW3yL10XpQQ0Ambepw6MXrhOnA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.2.tgz", + "integrity": "sha512-wnFJkogWvN4jm/hQRF2UBaeUmk20j5+DmHvoyWii2b8HJDyvz1MF2OU/6ynXt2KR63rbZLWkFpoytpdc/yBuSA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.2.tgz", + "integrity": "sha512-HVu2bp0zhvJ8xHEV9+UUs7S90VadmBSY3LcIMvozbPo4AuMGDWlz3ymHLHZPX4hR67TKTt8Qp5PJ5RBg/i+RMQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.2.tgz", + "integrity": "sha512-mQqqAV8QaoSgr9I2fKDLY2BAVvmKjWoGiu/cSYQonsLvtqwEn1E4QYfnCOcp5zoEqNhsDYin1s6jx/VJmrxlZg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.2.tgz", + "integrity": "sha512-IxKLoxCQ2IWi6bT2akyDUBGsOImDKB+sPp4EsTmwFQ/fMwpCKm8uLSSgP/Kx/QYUgKis6SEZ5/Nlhup0DIA0PQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.2.tgz", + "integrity": "sha512-Mk5ha2RQSgyFfmYYLkBpPnUk8D8FriBxesO1u9O75X0mHgXL1UQcH5Itl2lurWL2tj0RxV9b9tJgipac0hRY9A==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.2.tgz", + "integrity": "sha512-CjvEnqJL/0/TQ3TXX3OPIJ/kmBellrWd4heXUmHeJlTnmwjKpSJzoehLaL6Xk0ZnMHBu9dZuFADNOrtjF4v+2w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.2.tgz", + "integrity": "sha512-1SiZbzwdkaDURsew/tSOrooKiYy7EQGT6m8ufavAi9NEyQb/6VuIxFXAL1fqa4iZe3g4NbNk4P7J32z2tw5Mgg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.2.tgz", + "integrity": "sha512-nQts12zJ3NQRoE6uYljOH89v7szzLDvG2JD/vsX+vGXU8w/At1GowTZ5/7qeFQ8m7L55rpR8Okugnuo5bgjy2Q==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.2.tgz", + "integrity": "sha512-E9/ll019jhPIJgpzfZoIkBGhcz+kKNgVWYRY0zr9srBdPPFVpvOKW8VaJKUbeK+eZXyQF9ltME+Kk6affeaPgg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.2.tgz", + "integrity": "sha512-5BqxR/pshjey51iliyzTD5Xi3EN0aLmQ2lZ3lvefVV9c82BvrLo2/6OT55iifpWBufs6kdwWbuOKS841DrmK9A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.2.tgz", + "integrity": "sha512-uNN83XxQrRAh/w0/pmAfibcwyb6YWt4gP+dpnQKPVJshAloQ785ii8CT8ZCIxkGg9opVsvAlGhFitSm6D1Jjpg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.2.tgz", + "integrity": "sha512-srjEIxSH3LRnJN6THczDHWQplqEMFiAJrTab0msUryh9kwNpkICf3Ea6q6MN/2cZwRFUNx5w+h6Hpi4QuHS6Zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.2.tgz", + "integrity": "sha512-8hOJnxgbyObnCm5AlRA3A931xX19xq80RjVTKgJOvEKWqJruP/Uf12IbAOaDjjEXYRewwHLfmF0YRIdK3OwKWA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.2.tgz", + "integrity": "sha512-mmF4AY1i0hG/bLWUctUq59gtmgaSIRa3cu/A3JFRp/sCNEme2bgDEiDS22P9FbnJB8NJNF4jPJiSP5RHQpUTDg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.2.tgz", + "integrity": "sha512-DZgkknc6jhHrk46V25vbAM0zZkyP0nSDkJB8/dRkLTxv470dOmWDqGoEJl/9A0dFfS7yE3REOwNDxpHwSLSt0Q==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.2.tgz", + "integrity": "sha512-T6xr6ucWSFto+VGajA8YH26LdpHRuP4YLHEKAtCWvJDOlnmWcDZVCI2Jmjr+IFHDlt2zRaTAKE4tfjTaWLgJBg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.2.tgz", + "integrity": "sha512-BfzEnDJOt9T8M989/lA37EcJgat01wLRnoi5dQf3QzOH7jzpqTAzdDbVfRljVr5r+jzKqpbHeyOfAaXxAd0PAA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", + "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz", + "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-x64": "4.3.1", + "@tailwindcss/oxide-freebsd-x64": "4.3.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-x64-musl": "4.3.1", + "@tailwindcss/oxide-wasm32-wasi": "4.3.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz", + "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz", + "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz", + "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz", + "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz", + "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz", + "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz", + "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz", + "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz", + "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz", + "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", + "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz", + "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.1.tgz", + "integrity": "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.1", + "@tailwindcss/oxide": "4.3.1", + "tailwindcss": "4.3.1" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tweenjs/tween.js": { + "version": "23.1.3", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz", + "integrity": "sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/draco3d": { + "version": "1.4.10", + "resolved": "https://registry.npmjs.org/@types/draco3d/-/draco3d-1.4.10.tgz", + "integrity": "sha512-AX22jp8Y7wwaBgAixaSvkoG4M/+PlAcm3Qs4OW8yT9DM4xUpWKeFhLueTAyZF39pviAdcDdeJoACapiAceqNcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.20.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", + "integrity": "sha512-QWlFW2wf3nTjC13/DqRnBpR4ZO36VJH/JVBkA/vcnmbTBNQIlnObqyqZE1tUR7+Ni23Lda8R1BxMfbXRpCUx5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/offscreencanvas": { + "version": "2019.7.3", + "resolved": "https://registry.npmjs.org/@types/offscreencanvas/-/offscreencanvas-2019.7.3.tgz", + "integrity": "sha512-ieXiYmgSRXUDeOntE1InxjWyvEelZGP63M+cGuquuRLuIKKT1osnkXjxev9B7d1nXSug5vpunx+gNlbVxMlC9A==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/react-reconciler": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, + "node_modules/@types/stats.js": { + "version": "0.17.4", + "resolved": "https://registry.npmjs.org/@types/stats.js/-/stats.js-0.17.4.tgz", + "integrity": "sha512-jIBvWWShCvlBqBNIZt0KAshWpvSjhkwkEu4ZUcASoAvhmrgAUI2t1dXrjSL4xXVLB4FznPrIsX3nKXFl/Dt4vA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/three": { + "version": "0.184.1", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.1.tgz", + "integrity": "sha512-6q4VdiqVsrTRqmk62/BnlcAvIrnDM0zf2ZDVKI5kZiniWrSaOHaQzmbp+BNzoggc/8tgW412pL//wZIxu2PPTA==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@dimforge/rapier3d-compat": "~0.12.0", + "@tweenjs/tween.js": "~23.1.3", + "@types/stats.js": "*", + "@types/webxr": ">=0.5.17", + "fflate": "~0.8.2", + "meshoptimizer": "~1.1.1" + } + }, + "node_modules/@types/webxr": { + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@use-gesture/core": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/core/-/core-10.3.1.tgz", + "integrity": "sha512-WcINiDt8WjqBdUXye25anHiNxPc0VOrlT8F6LLkU6cycrOGUDyY/yyFmsg3k8i5OLvv25llc0QC45GhR/C8llw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@use-gesture/react": { + "version": "10.3.1", + "resolved": "https://registry.npmjs.org/@use-gesture/react/-/react-10.3.1.tgz", + "integrity": "sha512-Yy19y6O2GJq8f7CHf7L0nxL8bf4PZCPaVOCgJrusOeFHY1LvHgYXnmnXg6N5iwAnbgbZCDjo60SiM6IPJi9C5g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@use-gesture/core": "10.3.1" + }, + "peerDependencies": { + "react": ">= 16.8.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.38", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz", + "integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } }, "node_modules/body-parser/node_modules/content-type": { "version": "2.0.0", @@ -865,6 +2176,66 @@ "url": "https://opencollective.com/express" } }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "peer": true, + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/bytes": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", @@ -903,6 +2274,51 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/camera-controls": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-3.1.2.tgz", + "integrity": "sha512-xkxfpG2ECZ6Ww5/9+kf4mfg1VEYAoe9aDSY+IwF0UEs7qEzwy0aVRfs2grImIECs/PoBtWFrh7RXsQkwG922JA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=22.0.0", + "npm": ">=10.5.1" + }, + "peerDependencies": { + "three": ">=0.126.1" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/content-disposition": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", @@ -925,6 +2341,13 @@ "node": ">= 0.6" } }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, "node_modules/cookie": { "version": "0.7.2", "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", @@ -960,6 +2383,25 @@ "url": "https://opencollective.com/express" } }, + "node_modules/cross-env": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz", + "integrity": "sha512-+/HKd6EgcQCJGh2PSjZuUitQBQynKor4wrFbRg4DtAgS1aWO+gU52xpH7M9ScGgXSYmAVS9bIJ8EzuaGw0oNAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.1" + }, + "bin": { + "cross-env": "src/bin/cross-env.js", + "cross-env-shell": "src/bin/cross-env-shell.js" + }, + "engines": { + "node": ">=10.14", + "npm": ">=6", + "yarn": ">=1" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -974,6 +2416,13 @@ "node": ">= 8" } }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -1000,6 +2449,16 @@ "node": ">= 0.8" } }, + "node_modules/detect-gpu": { + "version": "5.0.70", + "resolved": "https://registry.npmjs.org/detect-gpu/-/detect-gpu-5.0.70.tgz", + "integrity": "sha512-bqerEP1Ese6nt3rFkwPnGbsUF9a4q+gMmpTVVOEzoCyeCc+y7/RvJnQZJx1JwhgQI5Ntg0Kgat8Uu7XpBqnz1w==", + "dev": true, + "license": "MIT", + "dependencies": { + "webgl-constants": "^1.1.1" + } + }, "node_modules/detect-libc": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", @@ -1009,6 +2468,13 @@ "node": ">=8" } }, + "node_modules/draco3d": { + "version": "1.5.7", + "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", + "integrity": "sha512-m6WCKt/erDXcw+70IJXnG7M3awwQPAsZvJGX5zY7beBqpELw6RDGkYVU0W43AFxye4pDZ5i2Lbyc/NNGqwjUVQ==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", @@ -1029,13 +2495,34 @@ "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", "license": "MIT" }, + "node_modules/electron-to-chromium": { + "version": "1.5.376", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.376.tgz", + "integrity": "sha512-cUVA7/RvbFTEuw/i3obUwDTRIXojaxkResf+ibByPFxjc6XK3VNtcQXV0NSbAlJ0FMjcJGgftVVB4Qo184EXvA==", + "dev": true, + "license": "ISC" + }, "node_modules/encodeurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", "license": "MIT", "engines": { - "node": ">= 0.8" + "node": ">= 0.8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" } }, "node_modules/es-define-property": { @@ -1552,6 +3039,16 @@ "node": ">=18" } }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -1672,6 +3169,31 @@ ], "license": "BSD-3-Clause" }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==", + "dev": true, + "license": "MIT" + }, "node_modules/finalhandler": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", @@ -1735,6 +3257,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -1772,6 +3304,13 @@ "node": ">= 0.4" } }, + "node_modules/glsl-noise": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/glsl-noise/-/glsl-noise-0.0.0.tgz", + "integrity": "sha512-b/ZCF6amfAUb7dJM/MxRs7AetQEahYzJ8PtgfrmEdtw6uyGOr+ZSGtgjFm6mfsBkxJ4d2W7kg+Nlqzqvn3Bc0w==", + "dev": true, + "license": "MIT" + }, "node_modules/gopd": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", @@ -1784,6 +3323,13 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, "node_modules/has-symbols": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", @@ -1808,6 +3354,13 @@ "node": ">= 0.4" } }, + "node_modules/hls.js": { + "version": "1.6.16", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.16.tgz", + "integrity": "sha512-VSIRpLfRwlAAdGL4wiTucx2ScRipo0ed1FBatWkyt832jC4CReKstga6yIhYVwGu9LOBjuX9wzmRMeQdBJtzEA==", + "dev": true, + "license": "Apache-2.0" + }, "node_modules/hono": { "version": "4.12.26", "resolved": "https://registry.npmjs.org/hono/-/hono-4.12.26.tgz", @@ -1831,85 +3384,440 @@ "toidentifier": "~1.0.1" }, "engines": { - "node": ">= 0.8" + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", + "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/its-fine": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/its-fine/-/its-fine-2.0.0.tgz", + "integrity": "sha512-KLViCmWx94zOvpLwSlsx6yOCeMhZYaxrJV87Po5k/FoZzcPSahvK5qJ7fYhS61sZi5ikmh2S3Hz55A2l3U69ng==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/react-reconciler": "^0.28.9" + }, + "peerDependencies": { + "react": "^19.0.0" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lie": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz", + "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://opencollective.com/parcel" } }, - "node_modules/iconv-lite": { - "version": "0.7.2", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz", - "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", - "license": "MIT", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3.0.0" - }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">=0.10.0" + "node": ">= 12.0.0" }, "funding": { "type": "opencollective", - "url": "https://opencollective.com/express" + "url": "https://opencollective.com/parcel" } }, - "node_modules/inherits": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", - "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", - "license": "ISC" - }, - "node_modules/ip-address": { - "version": "10.2.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.2.0.tgz", - "integrity": "sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==", - "license": "MIT", + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 12" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/ipaddr.js": { - "version": "1.9.1", - "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", - "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", - "license": "MIT", + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], "engines": { - "node": ">= 0.10" + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/is-promise": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", - "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", - "license": "MIT" - }, - "node_modules/isexe": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", - "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", - "license": "ISC" - }, - "node_modules/jose": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", - "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", - "license": "MIT", + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, "funding": { - "url": "https://github.com/sponsors/panva" + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, - "node_modules/json-schema-traverse": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", - "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", - "license": "MIT" - }, - "node_modules/json-schema-typed": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", - "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", - "license": "BSD-2-Clause" + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } }, "node_modules/lmdb": { "version": "3.5.6", @@ -1938,6 +3846,47 @@ "@lmdb/lmdb-win32-x64": "3.5.6" } }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.460.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.460.0.tgz", + "integrity": "sha512-BVtq/DykVeIvRTJvRAgCsOwaGL8Un3Bxh8MbDxMhEWlZay3T4IpEKDEpwt5KZ0KJMHzgm6jrltxlT5eXOWXDHg==", + "dev": true, + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0-rc" + } + }, + "node_modules/maath": { + "version": "0.10.8", + "resolved": "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz", + "integrity": "sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/three": ">=0.134.0", + "three": ">=0.134.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -1968,6 +3917,23 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/meshline": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/meshline/-/meshline-3.3.1.tgz", + "integrity": "sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "three": ">=0.137" + } + }, + "node_modules/meshoptimizer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-1.1.1.tgz", + "integrity": "sha512-oRFNWJRDA/WTrVj7NWvqa5HqE1t9MYDj2VaWirQCzCCrAd2GHrqR/sQezCxiWATPNlKTcRaPRHPJwIRoPBAp5g==", + "dev": true, + "license": "MIT" + }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -2030,6 +3996,25 @@ "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" } }, + "node_modules/nanoid": { + "version": "3.3.14", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.14.tgz", + "integrity": "sha512-U9kYi5bpVMEI31yC8iw4bJJp0avcHXA0W8/wNfLfnvJYzihQo2ZRPYPvpAAd570HAcCBjCTN7vnr+v4StKl1IQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/negotiator": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", @@ -2059,6 +4044,16 @@ "node-gyp-build-optional-packages-test": "build-test.js" } }, + "node_modules/node-releases": { + "version": "2.0.48", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz", + "integrity": "sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -2105,6 +4100,10 @@ "resolved": "mcp", "link": true }, + "node_modules/openvisio-viewer": { + "resolved": "viewer", + "link": true + }, "node_modules/ordered-binary": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/ordered-binary/-/ordered-binary-1.6.1.tgz", @@ -2139,6 +4138,27 @@ "url": "https://opencollective.com/express" } }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/pkce-challenge": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", @@ -2148,6 +4168,60 @@ "node": ">=16.20.0" } }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/potpack": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/potpack/-/potpack-1.0.2.tgz", + "integrity": "sha512-choctRBIV9EMT9WGAZHn3V7t0Z2pMQyl0EZE6pFc/6ml3ssw7Dlf/oAOvFwjm1HVsqfQN8GfeFyJ+d8tRzqueQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/promise-worker-transferable": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/promise-worker-transferable/-/promise-worker-transferable-1.0.4.tgz", + "integrity": "sha512-bN+0ehEnrXfxV2ZQvU2PetO0n4gqBD4ulq3MI1WOPLgr7/Mg9yRQkX5+0v1vagr74ZTsl7XtzlaYDo2EuCeYJw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "is-promise": "^2.1.0", + "lie": "^3.0.2" + } + }, + "node_modules/promise-worker-transferable/node_modules/is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", + "dev": true, + "license": "MIT" + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", @@ -2200,6 +4274,57 @@ "node": ">= 0.10" } }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-use-measure": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz", + "integrity": "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "react": ">=16.13", + "react-dom": ">=16.13" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -2209,6 +4334,51 @@ "node": ">=0.10.0" } }, + "node_modules/rollup": { + "version": "4.62.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", + "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.62.2", + "@rollup/rollup-android-arm64": "4.62.2", + "@rollup/rollup-darwin-arm64": "4.62.2", + "@rollup/rollup-darwin-x64": "4.62.2", + "@rollup/rollup-freebsd-arm64": "4.62.2", + "@rollup/rollup-freebsd-x64": "4.62.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.62.2", + "@rollup/rollup-linux-arm-musleabihf": "4.62.2", + "@rollup/rollup-linux-arm64-gnu": "4.62.2", + "@rollup/rollup-linux-arm64-musl": "4.62.2", + "@rollup/rollup-linux-loong64-gnu": "4.62.2", + "@rollup/rollup-linux-loong64-musl": "4.62.2", + "@rollup/rollup-linux-ppc64-gnu": "4.62.2", + "@rollup/rollup-linux-ppc64-musl": "4.62.2", + "@rollup/rollup-linux-riscv64-gnu": "4.62.2", + "@rollup/rollup-linux-riscv64-musl": "4.62.2", + "@rollup/rollup-linux-s390x-gnu": "4.62.2", + "@rollup/rollup-linux-x64-gnu": "4.62.2", + "@rollup/rollup-linux-x64-musl": "4.62.2", + "@rollup/rollup-openbsd-x64": "4.62.2", + "@rollup/rollup-openharmony-arm64": "4.62.2", + "@rollup/rollup-win32-arm64-msvc": "4.62.2", + "@rollup/rollup-win32-ia32-msvc": "4.62.2", + "@rollup/rollup-win32-x64-gnu": "4.62.2", + "@rollup/rollup-win32-x64-msvc": "4.62.2", + "fsevents": "~2.3.2" + } + }, "node_modules/router": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", @@ -2231,6 +4401,23 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/send": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", @@ -2375,6 +4562,45 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stats-gl": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/stats-gl/-/stats-gl-2.4.2.tgz", + "integrity": "sha512-g5O9B0hm9CvnM36+v7SFl39T7hmAlv541tU81ME8YeSb3i1CIP5/QdDeSB3A0la0bKNHpxpwxOVRo2wFTYEosQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/three": "*", + "three": "^0.170.0" + }, + "peerDependencies": { + "@types/three": "*", + "three": "*" + } + }, + "node_modules/stats-gl/node_modules/three": { + "version": "0.170.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.170.0.tgz", + "integrity": "sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/stats.js": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/stats.js/-/stats.js-0.17.0.tgz", + "integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==", + "dev": true, + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -2384,6 +4610,108 @@ "node": ">= 0.8" } }, + "node_modules/suspend-react": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz", + "integrity": "sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "react": ">=17.0" + } + }, + "node_modules/tailwind-merge": { + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-2.6.1.tgz", + "integrity": "sha512-Oo6tHdpZsGpkKG88HJ8RR1rg/RdnEkQEfMoEk2x1XRI3F1AxeU+ijRXpiVUF4UbLfcxxRGw6TbUINKYdWVsQTQ==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", + "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/three": { + "version": "0.184.0", + "resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz", + "integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/three-mesh-bvh": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/three-mesh-bvh/-/three-mesh-bvh-0.8.3.tgz", + "integrity": "sha512-4G5lBaF+g2auKX3P0yqx+MJC6oVt6sB5k+CchS6Ob0qvH0YIhuUk1eYr7ktsIpY+albCqE80/FVQGV190PmiAg==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "three": ">= 0.159.0" + } + }, + "node_modules/three-stdlib": { + "version": "2.36.1", + "resolved": "https://registry.npmjs.org/three-stdlib/-/three-stdlib-2.36.1.tgz", + "integrity": "sha512-XyGQrFmNQ5O/IoKm556ftwKsBg11TIb301MB5dWNicziQBEs2g3gtOYIf7pFiLa0zI2gUwhtCjv9fmjnxKZ1Cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/draco3d": "^1.4.0", + "@types/offscreencanvas": "^2019.6.4", + "@types/webxr": "^0.5.2", + "draco3d": "^1.4.1", + "fflate": "^0.6.9", + "potpack": "^1.0.1" + }, + "peerDependencies": { + "three": ">=0.128.0" + } + }, + "node_modules/three-stdlib/node_modules/fflate": { + "version": "0.6.10", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.6.10.tgz", + "integrity": "sha512-IQrh3lEPM93wVCEczc9SaAOvkmcoQn/G8Bo1e8ZPlY3X3bnAxWaBdvTdvM1hP62iZp0BXWDy4vTAy4fF0+Dlpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, "node_modules/toidentifier": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", @@ -2402,6 +4730,39 @@ "tree-sitter-wasms": "^0.1.11" } }, + "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==", + "dev": true, + "license": "MIT", + "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==", + "dev": true, + "license": "MIT", + "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==", + "dev": true, + "license": "MIT" + }, "node_modules/tsx": { "version": "4.22.4", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.22.4.tgz", @@ -2421,6 +4782,45 @@ "fsevents": "~2.3.3" } }, + "node_modules/tunnel-rat": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/tunnel-rat/-/tunnel-rat-0.1.2.tgz", + "integrity": "sha512-lR5VHmkPhzdhrM092lI2nACsLO4QubF0/yoOhzX7c+wIpbN1GjHNzCc91QlpxBi+cnx8vVJ+Ur6vL5cEoQPFpQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "zustand": "^4.3.2" + } + }, + "node_modules/tunnel-rat/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "dev": true, + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, "node_modules/type-is": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", @@ -2482,6 +4882,57 @@ "node": ">= 0.8" } }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/utility-types": { + "version": "3.11.0", + "resolved": "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz", + "integrity": "sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -2491,6 +4942,124 @@ "node": ">= 0.8" } }, + "node_modules/vite": { + "version": "6.4.3", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", + "integrity": "sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, "node_modules/weak-lru-cache": { "version": "1.2.2", "resolved": "https://registry.npmjs.org/weak-lru-cache/-/weak-lru-cache-1.2.2.tgz", @@ -2511,6 +5080,19 @@ } } }, + "node_modules/webgl-constants": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/webgl-constants/-/webgl-constants-1.1.1.tgz", + "integrity": "sha512-LkBXKjU5r9vAW7Gcu3T5u+5cvSvh5WwINdr0C+9jpzVB41cjQAP5ePArDtk/WHYdVj0GefCgM73BA7FlIiNtdg==", + "dev": 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==", + "dev": true, + "license": "MIT" + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -2532,6 +5114,13 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", "license": "ISC" }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, "node_modules/zod": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", @@ -2550,6 +5139,71 @@ "peerDependencies": { "zod": "^3.25.28 || ^4" } + }, + "node_modules/zustand": { + "version": "5.0.14", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", + "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + }, + "viewer": { + "name": "openvisio-viewer", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "@react-three/drei": "^10.7.7", + "@react-three/fiber": "^9.6.1", + "@tailwindcss/vite": "^4.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@types/three": "^0.184.0", + "@vitejs/plugin-react": "^4.3.4", + "clsx": "^2.1.1", + "lucide-react": "^0.460.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tailwind-merge": "^2.5.5", + "tailwindcss": "^4.0.0", + "three": "^0.184.0", + "three-stdlib": "^2.35.0", + "typescript": "^5.7.2", + "vite": "^6.0.0", + "zod": "^3.23.8" + } + }, + "viewer/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 3bd128e..0e7a3e2 100644 --- a/package.json +++ b/package.json @@ -14,10 +14,11 @@ }, "workspaces": [ "core", - "mcp" + "mcp", + "viewer" ], "scripts": { - "build": "npm run build -w @openvisio/core && npm run build -w openvisio", + "build": "npm run build -w @openvisio/core && npm run build -w openvisio-viewer && npm run build -w openvisio", "typecheck": "npm run typecheck -w @openvisio/core && npm run typecheck -w openvisio", "bench": "npm run build -w @openvisio/core && tsx bench/estimate.ts", "smoke": "npm run build && node mcp/smoke.mjs" diff --git a/viewer/index.html b/viewer/index.html new file mode 100644 index 0000000..835680f --- /dev/null +++ b/viewer/index.html @@ -0,0 +1,13 @@ + + + + + + OpenVisio — graph viewer + + + +
+ + + diff --git a/viewer/package.json b/viewer/package.json new file mode 100644 index 0000000..2044f88 --- /dev/null +++ b/viewer/package.json @@ -0,0 +1,36 @@ +{ + "name": "openvisio-viewer", + "version": "0.1.0", + "description": "Self-contained Atlas + City graph viewer for OpenVisio — the same React/Three.js views from the app, served locally by `openvisio view`.", + "license": "MIT", + "type": "module", + "files": [ + "dist" + ], + "scripts": { + "build": "vite build", + "dev": "vite", + "typecheck": "tsc --noEmit", + "prepublishOnly": "vite build" + }, + "devDependencies": { + "@react-three/drei": "^10.7.7", + "@react-three/fiber": "^9.6.1", + "@tailwindcss/vite": "^4.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@types/three": "^0.184.0", + "@vitejs/plugin-react": "^4.3.4", + "clsx": "^2.1.1", + "lucide-react": "^0.460.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "tailwind-merge": "^2.5.5", + "tailwindcss": "^4.0.0", + "three": "^0.184.0", + "three-stdlib": "^2.35.0", + "typescript": "^5.7.2", + "vite": "^6.0.0", + "zod": "^3.23.8" + } +} diff --git a/viewer/src/App.tsx b/viewer/src/App.tsx new file mode 100644 index 0000000..c16e79c --- /dev/null +++ b/viewer/src/App.tsx @@ -0,0 +1,146 @@ +// The open-source viewer shell. Mirrors the app's Workspace wiring — the same +// Atlas and City views, a toggle between them, a focused-file selection, and the +// live agent spotlight — but standalone: it indexes a local repo through the +// `openvisio` server's /api/graph endpoint, with no account, narrator, or AI. + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { AtlasView } from '@/components/graph/AtlasView' +import { CityView } from '@/components/city/CityView' +import { GraphResponseSchema, type GraphResponse } from '@/lib/api/types' +import { cn } from '@/lib/utils' + +type Mode = 'city' | 'atlas' + +export function App() { + const [source, setSource] = useState(() => new URLSearchParams(location.search).get('path') ?? '') + const [input, setInput] = useState(source) + const [graph, setGraph] = useState(null) + const [mode, setMode] = useState('city') + const [focusedFileId, setFocusedFileId] = useState(null) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [agent, setAgent] = useState<'off' | 'idle' | 'live'>('off') + const [agentTool, setAgentTool] = useState('') + + // No AI narrator here, so nothing is cited — the views take an empty map. + const citations = useMemo(() => new Map(), []) + + const indexRepo = useCallback(async (repoPath: string) => { + if (!repoPath) return + setLoading(true) + setError(null) + try { + const res = await fetch('/api/graph?path=' + encodeURIComponent(repoPath)) + const body = await res.json() + if (!res.ok) throw new Error(body?.error ?? 'index failed (' + res.status + ')') + setGraph(GraphResponseSchema.parse(body.graph)) + setFocusedFileId(null) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + setGraph(null) + } finally { + setLoading(false) + } + }, []) + + // Index whatever ?path= we booted with (or the user re-indexes via the box). + useEffect(() => { if (source) void indexRepo(source) }, [source, indexRepo]) + + // Live agent spotlight — same server, same origin. A tool call's focus[0] + // drives the focused file so the building/node lights up, exactly like the app. + const byPath = useMemo(() => { + const m = new Map() + for (const f of graph?.files ?? []) m.set(f.path, f.id) + return m + }, [graph]) + const fadeRef = useRef | null>(null) + useEffect(() => { + if (typeof EventSource === 'undefined') return + let es: EventSource + try { es = new EventSource('/api/spotlight') } catch { return } + es.addEventListener('spotlight', (ev) => { + let data: any + try { data = JSON.parse((ev as MessageEvent).data) } catch { return } + if (!data || data.v !== 1) return + if (data.kind && data.kind !== 'highlight') return + if (data.ts && Date.now() - data.ts > 15000) return + const first = (data.focus ?? []).map((p: string) => byPath.get(p)).find((id: number | undefined) => id != null) + if (first != null) setFocusedFileId(first) + setAgentTool(data.tool || 'agent') + setAgent('live') + if (fadeRef.current) clearTimeout(fadeRef.current) + fadeRef.current = setTimeout(() => setAgent('idle'), 5000) + }) + es.onopen = () => setAgent((a) => (a === 'live' ? a : 'idle')) + es.onerror = () => setAgent('off') + return () => es.close() + }, [byPath]) + + const onSubmit = (e: React.FormEvent) => { + e.preventDefault() + const p = input.trim() + if (!p) return + const url = new URL(location.href) + url.searchParams.set('path', p) + history.replaceState(null, '', url) + setSource(p) + } + + const repo = graph?.repo + return ( +
+
+
OpenVisio · viewer
+
+ setInput(e.target.value)} + spellCheck={false} + placeholder="/absolute/path/to/repo" + className="min-w-0 flex-1 rounded-md border border-[var(--color-border)] bg-[var(--color-bg)] px-3 py-1.5 font-mono text-xs outline-none focus:border-[var(--color-accent)]" + /> + +
+ {repo && ( +
+ {repo.file_count} files · {repo.total_loc.toLocaleString()} loc +
+ )} + {agent !== 'off' && ( + + {agent === 'live' ? '● ' + agentTool : '○ stream'} + + )} +
+ {(['city', 'atlas'] as Mode[]).map((m) => ( + + ))} +
+
+ +
+ {graph ? ( + mode === 'city' ? ( + + ) : ( + + ) + ) : ( +
+ {error ? Error: {error} + : loading ? Indexing {source}… + : Enter a local repo path above and press Index.} +
+ )} +
+
+ ) +} diff --git a/viewer/src/components/city/CityView.tsx b/viewer/src/components/city/CityView.tsx new file mode 100644 index 0000000..0a96ad7 --- /dev/null +++ b/viewer/src/components/city/CityView.tsx @@ -0,0 +1,1475 @@ +'use client' + +import { Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Canvas, useFrame, useThree, type ThreeEvent } from '@react-three/fiber' +import { GizmoHelper, GizmoViewcube, Html, Line, OrbitControls, Text } from '@react-three/drei' +import * as THREE from 'three' +import { Minus, Plus, Share2, Crosshair, Palette } from 'lucide-react' +import type { FileHistory, GraphResponse, Language } from '@/lib/api/types' +import { folderLabel, shortName } from '@/components/graph/encoding' +import { capFileGraph } from '@/lib/graph/capGraph' +import { cn } from '@/lib/utils' +import { + buildCityLayout, + COLOR_MODES, + FILE_TYPE_LABELS, + fileType, + fileTypeColor, + languageColor, + metricLegend, + resolveCityColor, + type BuildingT, + type CityLayoutT, + type ColorMode, + type FileType, + type LegendBand, +} from './cityLayout' +import type { Line2 as Line2Impl, OrbitControls as OrbitControlsImpl } from 'three-stdlib' +import { isTypingTarget } from '@/lib/utils/keyboardNav' + +export interface CityViewProps { + graph: GraphResponse + focusedFileId: number | null + citations: Map + onFocus: (fileId: number | null) => void + // Only respond to keyboard nav when the city is the visible mode; also gates + // the render loop (frameloop) so an off-screen embed costs ~0 GPU. + active?: boolean + // Hide all chrome (widgets, legend, pills, gizmo, hint) for a decorative + // embed — e.g. a landing-page showcase panel. + embed?: boolean + // Skip the cream backdrop so the city floats on the page behind it. + transparentBg?: boolean + // Restrict interaction to orbiting only — no zoom (which would hijack page + // scroll in an embed) and no pan. + rotateOnly?: boolean + // Scales the initial camera distance: <1 frames the city larger. Embeds use + // this to fill their panel; the app keeps the default comfortable margin. + fit?: number +} + +const BG = '#F4F1E8' // light cream — codecharta-ish ground around the city +const GROUND_DARK = '#1B1D22' // dark plinth under the buildings + +// Performance ceilings for big repos. Each building was 2–3 three.js meshes + a +// troika text label; uncapped that pins RAM/GPU and can lock a laptop. +// - CITY_FILE_CAP: hard cap on buildings (most-connected kept, rest dropped). +// - CITY_SHADOW_MAX: above this, shadows are disabled (the shadow map + every +// building rendering into it is a major cost). +// - CITY_LABEL_BUDGET: only the N largest base buildings get rooftop labels; +// labels (troika SDF geometry per name) are the single heaviest per-building +// cost. Focused/connected buildings are always labelled regardless. +// - CITY_EDGE_CAP: when nothing is focused, only the heaviest N imports draw. +const CITY_FILE_CAP = 1500 +const CITY_SHADOW_MAX = 1200 +const CITY_LABEL_BUDGET = 140 +const CITY_EDGE_CAP = 400 + +interface ViewportInfo { + cameraPos: [number, number, number] + distance: number + azimuth: number + polar: number +} + +export function CityView({ graph: rawGraph, focusedFileId, citations, onFocus, active = true, embed = false, transparentBg = false, rotateOnly = false, fit = 1 }: CityViewProps) { + // Cap before laying out the city so every downstream derivation is bounded. + const capped = useMemo(() => capFileGraph(rawGraph, CITY_FILE_CAP), [rawGraph]) + const graph = capped.graph + const layout = useMemo(() => buildCityLayout(graph), [graph]) + const shadowsOn = layout.buildings.length <= CITY_SHADOW_MAX + const [hovered, setHovered] = useState(null) + const [showEdges, setShowEdges] = useState(true) + const [colorMode, setColorMode] = useState('language') + + const colorCtx = useMemo( + () => ({ maxLoc: layout.maxLoc, maxHotness: layout.maxHotness, historyByFile: layout.historyByFile }), + [layout.maxLoc, layout.maxHotness, layout.historyByFile], + ) + + const buildingColors = useMemo(() => { + const m = new Map() + for (const b of layout.buildings) m.set(b.file.id, resolveCityColor(b.file, colorMode, colorCtx)) + return m + }, [layout.buildings, colorMode, colorCtx]) + + const controlsRef = useRef(null) + + // Index every (source → target) file pair by its raw weight, so an edge + // tooltip can show "N imports" rather than just an edge id. + const edgeWeightByPair = useMemo(() => { + const m = new Map() + for (const e of graph.edges) { + if (e.edge_kind !== 'import') continue + m.set(`${e.source_id}->${e.target_id}`, Math.max(1, e.weight ?? 1)) + } + return m + }, [graph.edges]) + + // Per-file import degree (fan-in / fan-out), so the focused-building label can + // show the same deterministic numbers the graph card does — "ask about what + // you see" without leaving the City. + const degreeByFile = useMemo(() => { + const m = new Map() + for (const e of graph.edges) { + if (e.edge_kind !== 'import' || e.source_kind !== 'file' || e.target_kind !== 'file') continue + const s = m.get(e.source_id) ?? { in: 0, out: 0 } + s.out++ + m.set(e.source_id, s) + const t = m.get(e.target_id) ?? { in: 0, out: 0 } + t.in++ + m.set(e.target_id, t) + } + return m + }, [graph.edges]) + + // When something's focused, the files at the other end of its edges are its + // direct neighbours — we light them up (amber, scaled by import strength) and + // fade the rest, so you can trace connections by eye. Mirrors the graph view. + const { connectedFileIds, neighborWeight, maxNeighborWeight } = useMemo(() => { + const ids = new Set() + const weight = new Map() + if (focusedFileId === null) return { connectedFileIds: ids, neighborWeight: weight, maxNeighborWeight: 1 } + for (const e of graph.edges) { + if (e.edge_kind !== 'import' || e.source_kind !== 'file' || e.target_kind !== 'file') continue + const other = e.source_id === focusedFileId ? e.target_id : e.target_id === focusedFileId ? e.source_id : null + if (other === null) continue + ids.add(other) + weight.set(other, (weight.get(other) ?? 0) + (e.weight || 1)) + } + return { connectedFileIds: ids, neighborWeight: weight, maxNeighborWeight: Math.max(1, ...weight.values()) } + }, [focusedFileId, graph.edges]) + + useCityKeyboard(active, controlsRef) + + const center: [number, number, number] = [layout.size.w / 2, 0, layout.size.d / 2] + // Frame the whole city with margin — fov is 45°, so 1.5× the largest side + // is a comfortable fit, then we lift the camera for an isometric-ish read. + const baseDistance = Math.max(layout.size.w, layout.size.d) * 1.5 * fit + const minDist = baseDistance * 0.15 + const maxDist = baseDistance * 4 + const initialCam: [number, number, number] = [ + center[0] + baseDistance * 0.65, + baseDistance * 0.55, + center[2] + baseDistance * 0.65, + ] + + const [viewport, setViewport] = useState({ + cameraPos: initialCam, + distance: baseDistance, + azimuth: 45, + polar: 60, + }) + + // Zoom percent: 100% = baseDistance. Smaller distance = larger %. + const zoomPct = Math.round((baseDistance / Math.max(viewport.distance, 0.001)) * 100) + + const setDistance = useCallback( + (next: number) => { + const controls = controlsRef.current + if (!controls) return + const cam = controls.object as THREE.PerspectiveCamera + const target = controls.target + const dir = new THREE.Vector3().subVectors(cam.position, target) + const len = dir.length() || 1 + dir.divideScalar(len) + const clamped = Math.max(minDist, Math.min(maxDist, next)) + cam.position.copy(target).addScaledVector(dir, clamped) + controls.update() + }, + [minDist, maxDist], + ) + + const handleZoomIn = useCallback(() => setDistance(viewport.distance * 0.8), [setDistance, viewport.distance]) + const handleZoomOut = useCallback(() => setDistance(viewport.distance * 1.25), [setDistance, viewport.distance]) + const handleZoomSlider = useCallback( + (pct: number) => { + // pct 0 → maxDist (way out); pct 100 → minDist (close in) + const t = Math.max(0, Math.min(1, pct / 100)) + const d = THREE.MathUtils.lerp(maxDist, minDist, t) + setDistance(d) + }, + [minDist, maxDist, setDistance], + ) + + const focusedBuilding = + focusedFileId !== null ? layout.buildingByFileId.get(focusedFileId) ?? null : null + + return ( +
+ {/* Top-right widget cluster — gizmo + zoom slider */} + {embed ? null : ( +
+ + {/* Placeholder for gizmo — actual gizmo is inside Canvas via GizmoHelper. + We just reserve a slot here so layout doesn't jump. */} +
+ + + +
+ )} + + {/* Adaptive legend — categorical for language/file_type, gradient for loc/hotness. */} + {embed ? null : } + + {/* Bottom-left: viewport vector readout — small, compact */} + {embed ? null : } + + {/* Large-repo notice: we capped the buildings to keep the city renderable. */} + {!embed && capped.truncated ? ( +
+ showing {capped.shownFiles.toLocaleString()} of {capped.totalFiles.toLocaleString()} files +
+ ) : null} + + {/* Focused file pill — bottom-center, only when focused */} + {!embed && focusedBuilding ? ( + onFocus(null)} /> + ) : null} + + onFocus(null)} + gl={{ toneMapping: THREE.NoToneMapping, outputColorSpace: THREE.SRGBColorSpace }} + > + {transparentBg ? null : } + + + + + + + + + {showEdges ? ( + + ) : null} + + {hovered !== null && hovered !== focusedFileId ? ( + + ) : null} + + + + + {embed ? null : ( + + + + )} + + + {/* Subtle controls hint */} + {embed ? null : ( +
+ drag · rotate ⌥ scroll · zoom ⌥ right-drag · pan + + ↑↓←→ pan · +↑↓←→ orbit · + / - zoom · F fit + +
+ )} +
+ ) +} + +function ViewportReporter({ onChange }: { onChange: (info: ViewportInfo) => void }) { + const { camera, controls } = useThree() as unknown as { camera: THREE.Camera; controls: { target: THREE.Vector3 } | null } + const last = useRef('') + useFrame(() => { + const pos = camera.position + const dir = new THREE.Vector3() + camera.getWorldDirection(dir) + const azimuth = (Math.atan2(-dir.z, dir.x) * 180) / Math.PI + const polar = (Math.acos(Math.min(1, Math.max(-1, dir.y))) * 180) / Math.PI + const target = controls?.target ?? new THREE.Vector3() + const distance = pos.distanceTo(target) + const next: ViewportInfo = { + cameraPos: [pos.x, pos.y, pos.z], + distance, + azimuth: Math.round(((azimuth % 360) + 360) % 360), + polar: Math.round(polar), + } + const key = `${next.cameraPos.map((n) => n.toFixed(1)).join(',')}|${next.distance.toFixed(1)}|${next.azimuth}|${next.polar}` + if (key !== last.current) { + last.current = key + onChange(next) + } + }) + return null +} + +function CityBase({ width, depth }: { width: number; depth: number }) { + // Dark plinth under the city — like the screenshot. + return ( + + + + + + + ) +} + +interface BuildingsProps { + layout: CityLayoutT + focusedFileId: number | null + connectedFileIds: Set + neighborWeight: Map + maxNeighborWeight: number + citations: Map + hovered: number | null + onHover: (id: number | null) => void + onClick: (id: number | null) => void + buildingColors: Map + shadowsOn: boolean +} + +// The bulk of the city is drawn as ONE InstancedMesh (one draw call, one object) +// instead of a React component + mesh per file. Only "special" buildings — +// focused, its connected neighbours, and cited ones — are rendered individually, +// because they need per-building emissive glow, halos, and badges the instanced +// material can't express. There are only ever a handful of those. +function Buildings({ + layout, + focusedFileId, + connectedFileIds, + neighborWeight, + maxNeighborWeight, + citations, + hovered, + onHover, + onClick, + buildingColors, + shadowsOn, +}: BuildingsProps) { + // Hover is excluded from the partition on purpose: it doesn't change a + // building's appearance (only the cursor + a floating label), so letting it + // flip buildings in/out of the instanced mesh would remount it on every hover. + const specialIds = useMemo(() => { + const s = new Set() + if (focusedFileId !== null) s.add(focusedFileId) + for (const id of connectedFileIds) s.add(id) + for (const id of citations.keys()) s.add(id) + return s + }, [focusedFileId, connectedFileIds, citations]) + + const baseBuildings = useMemo( + () => layout.buildings.filter((b) => !specialIds.has(b.file.id)), + [layout.buildings, specialIds], + ) + const specialBuildings = useMemo( + () => layout.buildings.filter((b) => specialIds.has(b.file.id)), + [layout.buildings, specialIds], + ) + + // Only the biggest base buildings get rooftop labels (troika text is the + // heaviest per-building cost). Special buildings always render their own label. + const labeledBase = useMemo(() => { + if (baseBuildings.length <= CITY_LABEL_BUDGET) return baseBuildings + return [...baseBuildings].sort((a, b) => b.file.loc - a.file.loc).slice(0, CITY_LABEL_BUDGET) + }, [baseBuildings]) + + return ( + + + + {specialBuildings.map((b) => { + const isConnected = connectedFileIds.has(b.file.id) + return ( + + ) + })} + + ) +} + +// One InstancedMesh for every "plain" building. A unit box is scaled + placed per +// instance via its matrix, and tinted via instanceColor — so thousands of files +// cost one draw call. When anything is focused, the whole base dims uniformly +// (the focused file's neighbours are rendered individually and stay bright). +function InstancedBuildings({ + buildings, + colors, + dimmed, + castShadow, + onHover, + onClick, +}: { + buildings: BuildingT[] + colors: Map + dimmed: boolean + castShadow: boolean + onHover: (id: number | null) => void + onClick: (id: number | null) => void +}) { + const meshRef = useRef(null) + const count = buildings.length + + useEffect(() => { + const mesh = meshRef.current + if (!mesh) return + const m = new THREE.Matrix4() + const q = new THREE.Quaternion() + const pos = new THREE.Vector3() + const scl = new THREE.Vector3() + const col = new THREE.Color() + for (let i = 0; i < buildings.length; i++) { + const b = buildings[i]! + pos.set(b.x + b.w / 2, b.h / 2, b.z + b.d / 2) + scl.set(b.w, b.h, b.d) + m.compose(pos, q, scl) + mesh.setMatrixAt(i, m) + col.set(colors.get(b.file.id) ?? b.color) + mesh.setColorAt(i, col) + } + mesh.instanceMatrix.needsUpdate = true + if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true + }, [buildings, colors]) + + if (count === 0) return null + + return ( + ) => { + e.stopPropagation() + const b = e.instanceId != null ? buildings[e.instanceId] : undefined + if (b) { + onHover(b.file.id) + document.body.style.cursor = 'pointer' + } + }} + onPointerOut={(e: ThreeEvent) => { + e.stopPropagation() + onHover(null) + document.body.style.cursor = 'auto' + }} + onClick={(e: ThreeEvent) => { + e.stopPropagation() + const b = e.instanceId != null ? buildings[e.instanceId] : undefined + if (b) onClick(b.file.id) + }} + > + + + + ) +} + +// Rooftop labels for the (gated) set of base buildings. Kept out of the +// InstancedMesh because troika is its own geometry per label. +function BuildingLabels({ + buildings, + colors, + dimmed, +}: { + buildings: BuildingT[] + colors: Map + dimmed: boolean +}) { + return ( + + {buildings.map((b) => ( + + ))} + + ) +} + +function BuildingLabel({ building, colorHex, dimmed }: { building: BuildingT; colorHex: string; dimmed: boolean }) { + const contrast = useMemo(() => readableTextColor(colorHex), [colorHex]) + const plan = useMemo(() => planLabel(shortName(building.file.path), building.w), [building.file.path, building.w]) + return ( + + {plan.text} + + ) +} + +interface BuildingProps { + building: BuildingT + color: string + isFocused: boolean + isConnected: boolean + importance?: number + isHovered: boolean + isDimmed: boolean + citationIndex: number | null + onHover: (id: number | null) => void + onClick: (id: number | null) => void + castShadow?: boolean +} + +// Amber glow for connected neighbours (matches the graph view's --color-hot-2). +const CONNECTED_GLOW = 0xf59e0b + +function Building({ + building, + color: colorHex, + isFocused, + isConnected, + importance = 0, + isHovered, + isDimmed, + citationIndex, + onHover, + onClick, + castShadow = true, +}: BuildingProps) { + const color = useMemo(() => new THREE.Color(colorHex), [colorHex]) + const emissive = useMemo( + () => + isFocused + ? new THREE.Color(colorHex).multiplyScalar(0.55) + : isConnected + ? new THREE.Color(CONNECTED_GLOW) + : new THREE.Color(0x000000), + [isFocused, isConnected, colorHex], + ) + // Connected neighbours glow amber, intensity scaled by import strength so the + // most important ones read strongest. Unconnected fade hard (the 3D "blur"). + const emissiveIntensity = isFocused ? 0.45 : isConnected ? 0.3 + importance * 0.5 : 0 + const opacity = isDimmed ? 0.16 : 1 + // Pick a text + outline pair that stays readable against the rooftop color. + const contrast = useMemo(() => readableTextColor(colorHex), [colorHex]) + // Plan the label so it always fits inside the rooftop footprint: pick a + // fontSize that scales the name down to the building width, and only + // truncate if even the minimum size would overflow. + const plan = useMemo( + () => planLabel(shortName(building.file.path), building.w), + [building.file.path, building.w], + ) + const labelOpacity = isDimmed ? 0.18 : 1 + + return ( + + ) => { + e.stopPropagation() + onHover(building.file.id) + document.body.style.cursor = 'pointer' + }} + onPointerOut={(e: ThreeEvent) => { + e.stopPropagation() + onHover(null) + document.body.style.cursor = 'auto' + }} + onClick={(e: ThreeEvent) => { + e.stopPropagation() + onClick(building.file.id) + }} + > + + + + + {/* File name printed flat on the rooftop. */} + + {plan.text} + + + {/* Connected halo — appears on neighbours of the focused file. */} + {isConnected && !isFocused ? ( + + + + + ) : null} + + {citationIndex !== null ? ( + +
+ {citationIndex} +
+ + ) : null} +
+ ) +} + +// WCAG-style relative luminance → pick ink-on-light or white-on-dark, plus a +// contrast outline color for legibility against the building's shading. +function readableTextColor(hex: string): { fg: string; outline: string } { + const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex) + if (!m) return { fg: '#ffffff', outline: '#000000' } + const toLinear = (c: number) => { + const s = c / 255 + return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4) + } + const r = toLinear(parseInt(m[1] ?? '0', 16)) + const g = toLinear(parseInt(m[2] ?? '0', 16)) + const b = toLinear(parseInt(m[3] ?? '0', 16)) + const lum = 0.2126 * r + 0.7152 * g + 0.0722 * b + return lum > 0.5 + ? { fg: '#0a0a0a', outline: '#ffffff' } + : { fg: '#ffffff', outline: '#000000' } +} + +// Plan a rooftop label that is guaranteed to fit inside the building's +// footprint. We scale the font down for long names first, and only +// truncate when even the minimum readable size would overflow. +const LABEL_MAX_FONT = 1.0 +const LABEL_MIN_FONT = 0.5 +// Approximate horizontal cost of a single character at fontSize=1.0 +// for the default sans font drei/troika ships with. +const LABEL_CHAR_WIDTH = 0.6 + +function planLabel(name: string, blockWidth: number): { text: string; fontSize: number } { + const target = blockWidth * 0.9 + // Step 1: pick the largest fontSize at which the full name fits. + const ideal = target / Math.max(1, name.length * LABEL_CHAR_WIDTH) + const fontSize = Math.max(LABEL_MIN_FONT, Math.min(LABEL_MAX_FONT, ideal)) + if (fontSize > LABEL_MIN_FONT) { + return { text: name, fontSize } + } + // Step 2: font is at the minimum and the name is still too long — truncate + // hard while preserving a short file extension when one exists. + const maxChars = Math.max(3, Math.floor(target / (LABEL_CHAR_WIDTH * LABEL_MIN_FONT))) + return { text: truncateName(name, maxChars), fontSize: LABEL_MIN_FONT } +} + +function truncateName(name: string, maxChars: number): string { + if (name.length <= maxChars) return name + const dot = name.lastIndexOf('.') + if (dot > 0 && name.length - dot <= 5) { + const ext = name.slice(dot) + const stemBudget = maxChars - ext.length - 1 + if (stemBudget >= 3) return name.slice(0, stemBudget) + '…' + ext + } + return name.slice(0, Math.max(1, maxChars - 1)) + '…' +} + +interface EdgeSegment { + points: THREE.Vector3[] + startpoint: THREE.Vector3 + endpoint: THREE.Vector3 + tangent: THREE.Vector3 + weight: number + src: BuildingT + dst: BuildingT + highlighted: boolean + dimmed: boolean +} + +function Edges({ + layout, + focusedFileId, + edgeWeightByPair, +}: { + layout: CityLayoutT + focusedFileId: number | null + edgeWeightByPair: Map +}) { + const segments = useMemo(() => { + // Bound how many arcs we build: when focused, only edges touching the focus; + // otherwise the heaviest CITY_EDGE_CAP imports. Each arc is several meshes + + // a per-frame dash animation, so an uncapped edge set is a real cost. + let sourceEdges = layout.edges + if (focusedFileId !== null) { + sourceEdges = layout.edges.filter( + (e) => e.source_id === focusedFileId || e.target_id === focusedFileId, + ) + } else if (layout.edges.length > CITY_EDGE_CAP) { + sourceEdges = [...layout.edges] + .sort( + (a, b) => + (edgeWeightByPair.get(`${b.source_id}->${b.target_id}`) ?? 1) - + (edgeWeightByPair.get(`${a.source_id}->${a.target_id}`) ?? 1), + ) + .slice(0, CITY_EDGE_CAP) + } + + const out: EdgeSegment[] = [] + for (const e of sourceEdges) { + const src = layout.buildingByFileId.get(e.source_id) + const dst = layout.buildingByFileId.get(e.target_id) + if (!src || !dst) continue + const a = new THREE.Vector3(src.x + src.w / 2, src.h + 0.4, src.z + src.d / 2) + const c = new THREE.Vector3(dst.x + dst.w / 2, dst.h + 0.4, dst.z + dst.d / 2) + const mid = new THREE.Vector3() + .addVectors(a, c) + .multiplyScalar(0.5) + .setY(Math.max(a.y, c.y) + a.distanceTo(c) * 0.32 + 3.5) + const curve = new THREE.QuadraticBezierCurve3(a, mid, c) + const points = curve.getPoints(36) + // Stop the line a touch before the target so it doesn't clip into + // the rooftop, and leave a clean landing for the arrow cone. + const tip = curve.getPoint(0.94) + const tangent = curve.getTangent(0.94).normalize() + points[points.length - 1] = tip + const highlighted = + focusedFileId !== null && (e.source_id === focusedFileId || e.target_id === focusedFileId) + const dimmed = focusedFileId !== null && !highlighted + out.push({ + points, + startpoint: a, + endpoint: tip, + tangent, + weight: edgeWeightByPair.get(`${e.source_id}->${e.target_id}`) ?? 1, + src, + dst, + highlighted, + dimmed, + }) + } + return out + }, [layout.edges, layout.buildingByFileId, focusedFileId, edgeWeightByPair]) + + return ( + + {segments.map((s, i) => ( + + ))} + + ) +} + +// Used to orient the arrow cone along the curve tangent. +const CONE_UP = new THREE.Vector3(0, 1, 0) + +function EdgeArc({ segment }: { segment: EdgeSegment }) { + const lineRef = useRef(null) + const [hovered, setHovered] = useState(false) + const [tooltipOpen, setTooltipOpen] = useState(false) + + // Animate a dash flow along the curve to communicate direction. We do + // it for every edge (subtle when idle, prominent when highlighted) so + // the city always feels alive. + useFrame((_, delta) => { + const obj = lineRef.current + const mat = obj?.material as { dashOffset?: number } | undefined + if (!mat || mat.dashOffset === undefined) return + mat.dashOffset -= delta * (segment.highlighted || hovered || tooltipOpen ? 5 : 1.2) + }) + + const active = segment.highlighted || hovered || tooltipOpen + const color = active ? '#0a0a0a' : '#475569' + const opacity = segment.dimmed && !hovered && !tooltipOpen ? 0.1 : active ? 1 : 0.6 + const lineWidth = active ? 3.6 : 1.8 + const markerSize = active ? 0.55 : 0.32 + const midpoint = useMemo(() => { + const pts = segment.points + return pts[Math.floor(pts.length / 2)] ?? segment.endpoint + }, [segment.points, segment.endpoint]) + + // Cone orientation: rotate the default +Y cone to face along the tangent. + const coneQuat = useMemo(() => { + const q = new THREE.Quaternion() + q.setFromUnitVectors(CONE_UP, segment.tangent) + return q + }, [segment.tangent]) + + return ( + { + e.stopPropagation() + setHovered(true) + document.body.style.cursor = 'pointer' + }} + onPointerOut={(e) => { + e.stopPropagation() + setHovered(false) + document.body.style.cursor = 'auto' + }} + onClick={(e) => { + e.stopPropagation() + setTooltipOpen((v) => !v) + }} + > + + + {/* Endpoint markers — easy visual ID for which buildings an edge connects. */} + + + + + + + + + + {/* Tooltip — opens on click; hover only highlights the line. */} + {tooltipOpen ? ( + +
e.stopPropagation()} + > +
+ {shortName(segment.src.file.path)} + + {shortName(segment.dst.file.path)} + +
+
+ {segment.weight} import{segment.weight === 1 ? '' : 's'} +
+
+ + ) : null} +
+ ) +} + +function FocusedLabel({ + building, + degree, + history, +}: { + building: BuildingT | null + degree?: { in: number; out: number } + history?: FileHistory +}) { + if (!building) return null + const inC = degree?.in ?? 0 + const outC = degree?.out ?? 0 + const churn = history?.commits_90d ?? 0 + return ( + +
+
+
+
+ {shortName(building.file.path)} +
+
+ {building.file.language} · {building.file.loc} LOC +
+
+ {inC} in · {outC} out · {churn} commits/90d +
+
+
+ + ) +} + +function HoverLabel({ building }: { building: BuildingT | null }) { + if (!building) return null + return ( + +
+ {shortName(building.file.path)} +
+ + ) +} + +function ZoomSlider({ + pct, + displayPct, + onChange, + onMinus, + onPlus, +}: { + pct: number + displayPct: number + onChange: (next: number) => void + onMinus: () => void + onPlus: () => void +}) { + return ( +
+ + onChange(Number(e.target.value))} + aria-label="Zoom level" + className="city-zoom-range h-1 w-[120px] cursor-pointer appearance-none bg-black/15 outline-none" + /> + + {displayPct}% + +
+ ) +} + +function ViewportPill({ info }: { info: ViewportInfo }) { + const compass = compassDir(info.azimuth) + return ( +
+
+ viewport + {compass} +
+
+ pos + + {info.cameraPos.map((n) => n.toFixed(0)).join(' · ')} + + azim + {info.azimuth}° + polar + {info.polar}° +
+
+ ) +} + +function compassDir(azimuthDeg: number): string { + const buckets = ['E', 'NE', 'N', 'NW', 'W', 'SW', 'S', 'SE'] + const i = Math.round(((azimuthDeg % 360) / 45)) % 8 + return buckets[i] ?? 'E' +} + +function Kbd({ children }: { children: React.ReactNode }) { + return ( + + {children} + + ) +} + +function FocusedPill({ building, onClear }: { building: BuildingT; onClear: () => void }) { + return ( +
+
+ + {shortName(building.file.path)} + + {building.file.language} · {building.file.loc} LOC + + +
+
+ ) +} + +// Folder names rendered on the base, sitting at ground level on the front edge +// of each district. Always face the camera via Html (sprite mode). +function DistrictLabels({ districts }: { districts: CityLayoutT['districts'] }) { + return ( + + {districts.map((d) => ( + + ))} + + ) +} + +function DistrictLabel({ district: d }: { district: CityLayoutT['districts'][number] }) { + const [hovered, setHovered] = useState(false) + return ( + +
setHovered(true)} + onPointerLeave={() => setHovered(false)} + > + {folderLabel(d.folder)} · {d.fileCount} +
+ {hovered ? ( +
+
{folderLabel(d.folder)}
+
+ {d.fileCount} file{d.fileCount === 1 ? '' : 's'} · {d.totalLoc} LOC +
+
+ ) : null} + + ) +} + +function ColorModePicker({ + mode, + onChange, +}: { + mode: ColorMode + onChange: (next: ColorMode) => void +}) { + const [open, setOpen] = useState(false) + const active = COLOR_MODES.find((m) => m.id === mode) ?? COLOR_MODES[0] + return ( +
+ + {open ? ( +
+ {COLOR_MODES.map((m) => ( + + ))} +
+ ) : null} +
+ ) +} + +function ColorLegend({ + mode, + graph, + layout, +}: { + mode: ColorMode + graph: GraphResponse + layout: CityLayoutT +}) { + if (mode === 'language') { + const counts = new Map() + for (const f of graph.files) counts.set(f.language, (counts.get(f.language) ?? 0) + 1) + const items = [...counts.entries()] + .map(([lang, count]) => ({ key: lang, label: lang, color: languageColor(lang), count })) + .sort((a, b) => b.count - a.count) + return + } + if (mode === 'file_type') { + const counts = new Map() + for (const f of graph.files) { + const t = fileType(f.path, f.language) + counts.set(t, (counts.get(t) ?? 0) + 1) + } + const items = [...counts.entries()] + .map(([t, count]) => ({ key: t, label: FILE_TYPE_LABELS[t], color: fileTypeColor(t), count })) + .sort((a, b) => b.count - a.count) + return + } + const bands = metricLegend(mode, { + maxLoc: layout.maxLoc, + maxHotness: layout.maxHotness, + historyByFile: layout.historyByFile, + }) + if (!bands) return null + return +} + +function LegendCategory({ + title, + items, +}: { + title: string + items: Array<{ key: string; label: string; color: string; count: number }> +}) { + return ( +
+
+ {title} +
+
    + {items.map(({ key, label, color, count }) => ( +
  • + + + {label} + + {count} +
  • + ))} +
+
+ ) +} + +// Keyboard nav for the 3D city. Arrows pan in screen space; shift+arrows +// orbit around the focal point; +/- dolly in/out; 0 or F resets the camera. +function useCityKeyboard( + active: boolean, + controlsRef: React.MutableRefObject, +) { + useEffect(() => { + if (!active) return + const handler = (e: KeyboardEvent) => { + if (isTypingTarget(e.target)) return + const ctrl = controlsRef.current + if (!ctrl) return + const cam = ctrl.object as THREE.PerspectiveCamera + const target = ctrl.target + + const rotateStep = (Math.PI / 180) * (e.shiftKey ? 10 : 6) + const panStep = e.shiftKey ? 22 : 10 + const dollyStep = 0.85 + + const offset = new THREE.Vector3().subVectors(cam.position, target) + + switch (e.key) { + case '+': + case '=': + offset.multiplyScalar(dollyStep) + cam.position.copy(target).add(offset) + ctrl.update() + e.preventDefault() + break + case '-': + case '_': + offset.divideScalar(dollyStep) + cam.position.copy(target).add(offset) + ctrl.update() + e.preventDefault() + break + case '0': + case 'f': + case 'F': + ctrl.reset?.() + e.preventDefault() + break + case 'ArrowLeft': + case 'ArrowRight': + case 'ArrowUp': + case 'ArrowDown': { + if (e.shiftKey) { + // Orbit. Horizontal arrows rotate around world Y; vertical arrows + // tilt around the camera's right axis (clamped via OrbitControls). + const horizontal = e.key === 'ArrowLeft' || e.key === 'ArrowRight' + const sign = e.key === 'ArrowLeft' || e.key === 'ArrowUp' ? 1 : -1 + const quat = new THREE.Quaternion() + if (horizontal) { + quat.setFromAxisAngle(new THREE.Vector3(0, 1, 0), sign * rotateStep) + } else { + const right = new THREE.Vector3().crossVectors(cam.up, offset).normalize() + quat.setFromAxisAngle(right, sign * rotateStep) + } + offset.applyQuaternion(quat) + cam.position.copy(target).add(offset) + cam.lookAt(target) + } else { + // Pan — translate camera and target together in screen space. + const forward = new THREE.Vector3().subVectors(target, cam.position).normalize() + const right = new THREE.Vector3().crossVectors(forward, cam.up).normalize() + const up = new THREE.Vector3().crossVectors(right, forward).normalize() + let dx = 0 + let dy = 0 + if (e.key === 'ArrowLeft') dx = -panStep + if (e.key === 'ArrowRight') dx = panStep + if (e.key === 'ArrowUp') dy = panStep + if (e.key === 'ArrowDown') dy = -panStep + const shift = new THREE.Vector3() + .addScaledVector(right, dx) + .addScaledVector(up, dy) + cam.position.add(shift) + target.add(shift) + } + ctrl.update() + e.preventDefault() + break + } + } + } + window.addEventListener('keydown', handler) + return () => window.removeEventListener('keydown', handler) + }, [active, controlsRef]) +} + +function LegendGradient({ title, bands }: { title: string; bands: LegendBand[] }) { + return ( +
+
+ {title} + reserved scale +
+
b.color).join(', ')})`, + }} + /> +
+ {bands.map((b, i) => ( + {b.label} + ))} +
+
+ ) +} diff --git a/viewer/src/components/city/cityLayout.ts b/viewer/src/components/city/cityLayout.ts new file mode 100644 index 0000000..04f70cc --- /dev/null +++ b/viewer/src/components/city/cityLayout.ts @@ -0,0 +1,416 @@ +import type { File, FileHistory, GraphResponse, Language } from '@/lib/api/types' +import { topLevelFolder } from '@/components/graph/encoding' + +// ---------- Color encodings ---------- + +export type ColorMode = 'language' | 'file_type' | 'loc' | 'hotness' + +export interface ColorModeDef { + id: ColorMode + label: string + hint: string + kind: 'category' | 'metric' +} + +export const COLOR_MODES: ColorModeDef[] = [ + { id: 'language', label: 'Language', hint: 'TypeScript, Python, etc.', kind: 'category' }, + { id: 'file_type', label: 'File type', hint: 'tests, configs, types, docs', kind: 'category' }, + { id: 'loc', label: 'LOC heatmap', hint: 'small → large', kind: 'metric' }, + { id: 'hotness', label: 'Hotness', hint: 'commits in last 30d', kind: 'metric' }, +] + +// Categorical palettes — language and file_type. +const LANGUAGE_PALETTE: Record = { + typescript: '#4DA3FF', + javascript: '#FFD43B', + python: '#5BA8FF', + go: '#22D3EE', + rust: '#FB923C', + java: '#F472B6', + ruby: '#F87171', + c: '#94A3B8', + cpp: '#F06595', + csharp: '#51CF66', + php: '#9AA0E8', + twig: '#B6D94C', + blade: '#FB7185', + kotlin: '#C792EA', + swift: '#FF9E64', + scala: '#FB7185', + lua: '#7AA2F7', + bash: '#A6E22E', + html: '#FB923C', + css: '#A78BFA', + scss: '#F472B6', + vue: '#4ADE80', + svelte: '#FB7185', + dart: '#2DD4BF', + elixir: '#C792EA', + ocaml: '#FBBF24', + solidity: '#B0AAA2', + zig: '#F0A868', + r: '#5B9BE0', + elm: '#7FD0E0', + rescript: '#FB7185', + tlaplus: '#94A3B8', + objc: '#7AA2F7', + embedded_template: '#B8B2A6', + systemrdl: '#A8A29E', + ql: '#FB923C', + elisp: '#C792EA', + markdown: '#C4B5FD', + json: '#94A3B8', + yaml: '#FCA5A5', + toml: '#D6A57E', + sql: '#F0ABFC', + graphql: '#F472B6', + eda: '#C8895A', // copper — PCB traces (KiCad/EAGLE/Gerber) + other: '#A8A29E', +} + +export type FileType = 'source' | 'test' | 'config' | 'types' | 'docs' | 'style' | 'build' + +const FILE_TYPE_PALETTE: Record = { + source: '#3B82F6', // blue — main source + test: '#22C55E', // green — tests + config: '#F59E0B', // amber — config + types: '#A78BFA', // purple — type definitions + docs: '#64748B', // slate — docs + style: '#EC4899', // pink — styles + build: '#475569', // graphite — build / lockfiles +} + +export const FILE_TYPE_LABELS: Record = { + source: 'source', + test: 'tests', + config: 'config', + types: 'types', + docs: 'docs', + style: 'styles', + build: 'build', +} + +// Reserved metric palettes — these never overlap with categorical encodings. +const LOC_HEATMAP = ['#22C55E', '#FACC15', '#FB923C', '#EF4444', '#7F1D1D'] // green → deep red +const HOTNESS_HEATMAP = ['#CBD5E1', '#60A5FA', '#FACC15', '#FB923C', '#DC2626'] // cold gray → red + +export function languageColor(language: Language): string { + return LANGUAGE_PALETTE[language] ?? LANGUAGE_PALETTE.other +} + +export function fileType(path: string, language: Language): FileType { + const lower = path.toLowerCase() + if (/\.(test|spec)\.[jt]sx?$/.test(lower)) return 'test' + if (/(^|\/)(__tests__|tests?|spec|e2e)\//.test(lower)) return 'test' + if (/\.d\.ts$/.test(lower)) return 'types' + if (/(^|\/)(tsconfig|next\.config|tailwind\.config|postcss\.config|vite\.config|webpack\.config|jest\.config|babel\.config|eslint\.config)/.test(lower)) return 'config' + if (/\.(eslintrc|prettierrc|babelrc|gitignore)/.test(lower)) return 'config' + if (/(^|\/)(package(-lock)?|yarn\.lock|pnpm-lock|bun\.lock|cargo\.lock|go\.sum|requirements\.txt)/.test(lower)) return 'build' + if (/\.(css|scss|sass|less)$/.test(lower)) return 'style' + if (language === 'markdown') return 'docs' + if (language === 'json' || language === 'yaml') return 'config' + return 'source' +} + +export function fileTypeColor(t: FileType): string { + return FILE_TYPE_PALETTE[t] +} + +function interpolatePalette(palette: string[], t: number): string { + const clamped = Math.max(0, Math.min(1, t)) + if (palette.length === 0) return '#000000' + if (palette.length === 1) return palette[0] ?? '#000000' + const idx = clamped * (palette.length - 1) + const lo = Math.floor(idx) + const hi = Math.min(palette.length - 1, lo + 1) + const frac = idx - lo + return mixHex(palette[lo] ?? '#000000', palette[hi] ?? '#000000', frac) +} + +function mixHex(a: string, b: string, t: number): string { + const ar = parseInt(a.slice(1, 3), 16) + const ag = parseInt(a.slice(3, 5), 16) + const ab = parseInt(a.slice(5, 7), 16) + const br = parseInt(b.slice(1, 3), 16) + const bg = parseInt(b.slice(3, 5), 16) + const bb = parseInt(b.slice(5, 7), 16) + const r = Math.round(ar + (br - ar) * t) + const g = Math.round(ag + (bg - ag) * t) + const bl = Math.round(ab + (bb - ab) * t) + return `#${r.toString(16).padStart(2, '0')}${g.toString(16).padStart(2, '0')}${bl.toString(16).padStart(2, '0')}` +} + +export interface ColorContext { + maxLoc: number + maxHotness: number + historyByFile: Map +} + +export function resolveCityColor(file: File, mode: ColorMode, ctx: ColorContext): string { + if (mode === 'language') return languageColor(file.language) + if (mode === 'file_type') return fileTypeColor(fileType(file.path, file.language)) + if (mode === 'loc') { + const t = Math.log(file.loc + 1) / Math.log(Math.max(ctx.maxLoc, 2) + 1) + return interpolatePalette(LOC_HEATMAP, t) + } + if (mode === 'hotness') { + const h = ctx.historyByFile.get(file.id)?.commits_30d ?? 0 + const t = Math.log(h + 1) / Math.log(Math.max(ctx.maxHotness, 2) + 1) + return interpolatePalette(HOTNESS_HEATMAP, t) + } + return languageColor(file.language) +} + +// Default city color = language. Kept for back-compat with old call sites. +export function cityColor(language: Language): string { + return languageColor(language) +} + +// Legend bands for metric modes — used by the legend UI. +export interface LegendBand { + label: string + color: string +} + +export function metricLegend(mode: ColorMode, ctx: ColorContext): LegendBand[] | null { + if (mode === 'loc') { + const max = Math.max(ctx.maxLoc, 1) + return [ + { label: '0', color: interpolatePalette(LOC_HEATMAP, 0) }, + { label: `${Math.round(max * 0.25)}`, color: interpolatePalette(LOC_HEATMAP, 0.25) }, + { label: `${Math.round(max * 0.5)}`, color: interpolatePalette(LOC_HEATMAP, 0.5) }, + { label: `${Math.round(max * 0.75)}`, color: interpolatePalette(LOC_HEATMAP, 0.75) }, + { label: `${max}+`, color: interpolatePalette(LOC_HEATMAP, 1) }, + ] + } + if (mode === 'hotness') { + const max = Math.max(ctx.maxHotness, 1) + return [ + { label: '0', color: interpolatePalette(HOTNESS_HEATMAP, 0) }, + { label: `${Math.round(max * 0.25)}`, color: interpolatePalette(HOTNESS_HEATMAP, 0.25) }, + { label: `${Math.round(max * 0.5)}`, color: interpolatePalette(HOTNESS_HEATMAP, 0.5) }, + { label: `${Math.round(max * 0.75)}`, color: interpolatePalette(HOTNESS_HEATMAP, 0.75) }, + { label: `${max}`, color: interpolatePalette(HOTNESS_HEATMAP, 1) }, + ] + } + return null +} + +export interface BuildingT { + file: File + x: number + z: number + w: number + d: number + h: number + color: string +} + +export interface DistrictT { + folder: string + x: number + z: number + w: number + d: number + fileCount: number + totalLoc: number +} + +export interface CityEdgeT { + id: number + source_id: number + target_id: number +} + +export interface CityTotalsT { + totalLoc: number + tallest: BuildingT | null + largestDistrict: DistrictT | null + hub: { file: File; connections: number } | null +} + +export interface CityLayoutT { + districts: DistrictT[] + buildings: BuildingT[] + buildingByFileId: Map + edges: CityEdgeT[] + incoming: Map + outgoing: Map + totals: CityTotalsT + size: { w: number; d: number } + maxLoc: number + maxHotness: number + historyByFile: Map +} + +// Wider footprints, lower skyline — buildings should read as proper squat +// blocks rather than thin spikes. Height still scales with LOC so the ratio +// is preserved; only the magnitude shrinks. +const TILE = 7 +const TILE_PAD = 2.2 // gap between buildings — visible breathing room +const FOLDER_PAD = 3 +const FOLDER_LABEL_PAD = 5 +const FOLDER_GAP = 8 +const FOLDERS_PER_ROW_TARGET_RATIO = 1.4 // aspect ratio of overall city: w / d + +const MIN_H = 0.8 +const MAX_H = 24 + +export function buildCityLayout(graph: GraphResponse): CityLayoutT { + const byFolder = new Map() + for (const f of graph.files) { + const k = topLevelFolder(f.path) + const arr = byFolder.get(k) ?? [] + arr.push(f) + byFolder.set(k, arr) + } + + // Sort folders by file count desc — biggest districts first. + const folders = [...byFolder.entries()] + .map(([folder, files]) => ({ folder, files: files.sort((a, b) => b.loc - a.loc) })) + .sort((a, b) => b.files.length - a.files.length) + + // Compute each folder's inner grid (uniform TILE per file). + const folderDims = folders.map(({ files }) => { + const cols = Math.max(1, Math.ceil(Math.sqrt(files.length * FOLDERS_PER_ROW_TARGET_RATIO))) + const rows = Math.ceil(files.length / cols) + const innerW = cols * TILE + (cols - 1) * TILE_PAD + const innerD = rows * TILE + (rows - 1) * TILE_PAD + return { + cols, + rows, + w: innerW + FOLDER_PAD * 2, + d: innerD + FOLDER_PAD * 2 + FOLDER_LABEL_PAD, + } + }) + + // Pick folders-per-row to approximate target aspect ratio. + const totalFolderArea = folderDims.reduce((s, d) => s + d.w * d.d, 0) + const targetCityW = Math.sqrt(totalFolderArea * FOLDERS_PER_ROW_TARGET_RATIO) + let perRow = 1 + for (let candidate = 1; candidate <= folders.length; candidate++) { + let rowWidth = 0 + for (let i = 0; i < candidate; i++) rowWidth += (folderDims[i]?.w ?? 0) + FOLDER_GAP + if (rowWidth > targetCityW) { + perRow = Math.max(1, candidate) + break + } + perRow = candidate + } + + // Place folders in a row-major grid: each row's height = max d of folders in that row, + // each row stacked vertically. + const rowHeights: number[] = [] + const colWidths: number[] = Array(perRow).fill(0) + folderDims.forEach((dim, i) => { + const col = i % perRow + const row = Math.floor(i / perRow) + rowHeights[row] = Math.max(rowHeights[row] ?? 0, dim.d) + colWidths[col] = Math.max(colWidths[col] ?? 0, dim.w) + }) + + const colOffsets: number[] = [0] + for (let i = 1; i < perRow; i++) { + colOffsets[i] = (colOffsets[i - 1] ?? 0) + (colWidths[i - 1] ?? 0) + FOLDER_GAP + } + const rowOffsets: number[] = [0] + for (let i = 1; i < rowHeights.length; i++) { + rowOffsets[i] = (rowOffsets[i - 1] ?? 0) + (rowHeights[i - 1] ?? 0) + FOLDER_GAP + } + + const districts: DistrictT[] = [] + const buildings: BuildingT[] = [] + const maxLoc = Math.max(1, ...graph.files.map((f) => f.loc)) + + folders.forEach(({ folder, files }, i) => { + const dim = folderDims[i] + if (!dim) return + const col = i % perRow + const row = Math.floor(i / perRow) + const x = colOffsets[col] ?? 0 + const z = rowOffsets[row] ?? 0 + const totalLoc = files.reduce((s, f) => s + Math.max(f.loc, 1), 0) + + districts.push({ + folder, + x, + z, + w: dim.w, + d: dim.d, + fileCount: files.length, + totalLoc, + }) + + files.forEach((file, idx) => { + const c = idx % dim.cols + const r = Math.floor(idx / dim.cols) + const bx = x + FOLDER_PAD + c * (TILE + TILE_PAD) + const bz = z + FOLDER_PAD + FOLDER_LABEL_PAD + r * (TILE + TILE_PAD) + const t = Math.log(file.loc + 1) / Math.log(maxLoc + 1) + buildings.push({ + file, + x: bx, + z: bz, + w: TILE, + d: TILE, + h: MIN_H + t * (MAX_H - MIN_H), + color: cityColor(file.language), + }) + }) + }) + + const buildingByFileId = new Map() + for (const b of buildings) buildingByFileId.set(b.file.id, b) + + const edges: CityEdgeT[] = [] + const incoming = new Map() + const outgoing = new Map() + for (const e of graph.edges) { + if (e.edge_kind !== 'import' || e.source_kind !== 'file' || e.target_kind !== 'file') continue + if (!buildingByFileId.has(e.source_id) || !buildingByFileId.has(e.target_id)) continue + edges.push({ id: e.id, source_id: e.source_id, target_id: e.target_id }) + const out = outgoing.get(e.source_id) ?? [] + out.push(e.target_id) + outgoing.set(e.source_id, out) + const inc = incoming.get(e.target_id) ?? [] + inc.push(e.source_id) + incoming.set(e.target_id, inc) + } + + let totalLoc = 0 + for (const f of graph.files) totalLoc += f.loc + + let tallest: BuildingT | null = null + for (const b of buildings) if (!tallest || b.h > tallest.h) tallest = b + + let largestDistrict: DistrictT | null = null + for (const d of districts) if (!largestDistrict || d.fileCount > largestDistrict.fileCount) largestDistrict = d + + let hub: { file: File; connections: number } | null = null + for (const b of buildings) { + const c = (incoming.get(b.file.id)?.length ?? 0) + (outgoing.get(b.file.id)?.length ?? 0) + if (!hub || c > hub.connections) hub = { file: b.file, connections: c } + } + + // Total city footprint: + const totalW = colOffsets.reduce((s, off, i) => Math.max(s, off + (colWidths[i] ?? 0)), 0) + const totalD = rowOffsets.reduce((s, off, i) => Math.max(s, off + (rowHeights[i] ?? 0)), 0) + + const historyByFile = new Map(graph.history.map((h) => [h.file_id, h])) + let maxHotness = 0 + for (const h of graph.history) if (h.commits_30d > maxHotness) maxHotness = h.commits_30d + + return { + districts, + buildings, + buildingByFileId, + edges, + incoming, + outgoing, + totals: { totalLoc, tallest, largestDistrict, hub }, + size: { w: Math.max(totalW, 1), d: Math.max(totalD, 1) }, + maxLoc, + maxHotness, + historyByFile, + } +} diff --git a/viewer/src/components/city/treemap.ts b/viewer/src/components/city/treemap.ts new file mode 100644 index 0000000..654110a --- /dev/null +++ b/viewer/src/components/city/treemap.ts @@ -0,0 +1,97 @@ +// Squarified treemap (Bruls, Huijsen, van Wijk 2000) — packs weighted items +// into a rectangle while keeping aspect ratios close to 1. This is what +// CodeCharta uses to lay out files inside folder bounding boxes. + +export interface Rect { + x: number + z: number + w: number + d: number +} + +interface Item { + id: K + value: number +} + +export function squarifiedTreemap(items: Item[], bbox: Rect): Map { + const out = new Map() + const sorted = items.filter((i) => i.value > 0).sort((a, b) => b.value - a.value) + if (sorted.length === 0 || bbox.w <= 0 || bbox.d <= 0) return out + + const totalValue = sorted.reduce((s, i) => s + i.value, 0) + const scale = (bbox.w * bbox.d) / totalValue + const scaled = sorted.map((i) => ({ id: i.id, value: i.value * scale })) + + squarify(scaled, [], bbox, out) + return out +} + +function squarify(items: Item[], row: Item[], container: Rect, out: Map) { + while (items.length > 0) { + const w = Math.min(container.w, container.d) + if (w === 0) { + if (row.length > 0) container = layoutRow(row, container, out) + row = [] + break + } + const head = items[0] + if (head === undefined) break + const candidate = [...row, head] + if (row.length === 0 || worstAspect(candidate, w) <= worstAspect(row, w)) { + row = candidate + items = items.slice(1) + } else { + container = layoutRow(row, container, out) + row = [] + } + } + if (row.length > 0) layoutRow(row, container, out) +} + +function worstAspect(row: Item[], w: number): number { + let sum = 0 + let rmax = -Infinity + let rmin = Infinity + for (const r of row) { + sum += r.value + if (r.value > rmax) rmax = r.value + if (r.value < rmin) rmin = r.value + } + if (sum === 0 || w === 0) return Infinity + return Math.max((w * w * rmax) / (sum * sum), (sum * sum) / (w * w * rmin)) +} + +function layoutRow(row: Item[], container: Rect, out: Map): Rect { + const sum = row.reduce((a, b) => a + b.value, 0) + const horizontal = container.w >= container.d + if (horizontal) { + const rowHeight = sum / container.w + let cursor = container.x + for (const item of row) { + const itemW = item.value / rowHeight + out.set(item.id, { x: cursor, z: container.z, w: itemW, d: rowHeight }) + cursor += itemW + } + return { + x: container.x, + z: container.z + rowHeight, + w: container.w, + d: container.d - rowHeight, + } + } else { + const rowWidth = sum / container.d + let cursor = container.z + for (const item of row) { + const itemD = item.value / rowWidth + out.set(item.id, { x: container.x, z: cursor, w: rowWidth, d: itemD }) + cursor += itemD + } + return { + x: container.x + rowWidth, + z: container.z, + w: container.w - rowWidth, + d: container.d, + } + } +} diff --git a/viewer/src/components/graph/AtlasView.tsx b/viewer/src/components/graph/AtlasView.tsx new file mode 100644 index 0000000..32c6556 --- /dev/null +++ b/viewer/src/components/graph/AtlasView.tsx @@ -0,0 +1,446 @@ +'use client' + +import { useEffect, useMemo, useRef, useState } from 'react' +import { Canvas, useThree, type ThreeEvent } from '@react-three/fiber' +import { OrbitControls } from '@react-three/drei' +import * as THREE from 'three' +import type { GraphResponse } from '@/lib/api/types' +import { + ATLAS_LINK_COLOR, + ATLAS_NODE_COLOR, + buildAtlas, + type AtlasData, + type AtlasLinkKind, + type AtlasNode, + type AtlasNodeType, +} from '@/lib/graph/atlas' + +export interface AtlasViewProps { + graph: GraphResponse + focusedFileId: number | null + onFocus: (fileId: number | null) => void + active?: boolean + /** Hide chrome (the legend) when used as a decorative/hero backdrop. */ + embed?: boolean +} + +const NODE_TYPES: AtlasNodeType[] = ['file', 'function', 'class', 'interface', 'type', 'const'] +const LINK_KINDS: AtlasLinkKind[] = ['imports', 'defines', 'calls'] +const NODE_LABEL: Record = { + file: 'File', + function: 'Function', + class: 'Class', + interface: 'Interface', + type: 'Type', + const: 'Const', +} + +const BG = '#06070b' + +// Soft additive glow point sprite — gives the galaxy bloom in one draw call, +// no postprocessing pass needed. +const NODE_VERT = ` + attribute float size; + attribute vec3 aColor; + varying vec3 vColor; + uniform float uScale; + void main() { + vColor = aColor; + vec4 mv = modelViewMatrix * vec4(position, 1.0); + gl_PointSize = clamp(size * uScale / max(1.0, -mv.z), 1.5, 64.0); + gl_Position = projectionMatrix * mv; + } +` +const NODE_FRAG = ` + varying vec3 vColor; + void main() { + vec2 c = gl_PointCoord - vec2(0.5); + float d = length(c) * 2.0; // 0 center .. 1 edge + float core = smoothstep(0.55, 0.0, d); + float halo = smoothstep(1.0, 0.0, d); + float a = clamp(core + halo * 0.35, 0.0, 1.0); + if (a <= 0.001) discard; + vec3 col = vColor * (0.55 + core * 0.9) + vec3(core * core * 0.5); + gl_FragColor = vec4(col, a); + } +` + +interface Bounds { + cx: number + cy: number + cz: number + dist: number + pickThreshold: number +} + +function toColor(hex: string, boost: number): [number, number, number] { + const c = new THREE.Color(hex) + return [c.r * boost, c.g * boost, c.b * boost] +} + +export function AtlasView({ graph, focusedFileId, onFocus, active = true, embed = false }: AtlasViewProps) { + const atlas = useMemo(() => buildAtlas(graph), [graph]) + const wrapRef = useRef(null) + const [hiddenTypes, setHiddenTypes] = useState>(new Set()) + const [hiddenLinks, setHiddenLinks] = useState>(new Set()) + const [hover, setHover] = useState<{ x: number; y: number; text: string } | null>(null) + + const bounds = useMemo(() => { + let minX = Infinity + let minY = Infinity + let minZ = Infinity + let maxX = -Infinity + let maxY = -Infinity + let maxZ = -Infinity + for (const n of atlas.nodes) { + if (n.x < minX) minX = n.x + if (n.y < minY) minY = n.y + if (n.z < minZ) minZ = n.z + if (n.x > maxX) maxX = n.x + if (n.y > maxY) maxY = n.y + if (n.z > maxZ) maxZ = n.z + } + if (!Number.isFinite(minX)) return { cx: 0, cy: 0, cz: 0, dist: 1000, pickThreshold: 6 } + const cx = (minX + maxX) / 2 + const cy = (minY + maxY) / 2 + const cz = (minZ + maxZ) / 2 + const extent = Math.max(maxX - minX, maxY - minY, maxZ - minZ, 50) + return { cx, cy, cz, dist: extent * 1.2, pickThreshold: Math.max(5, extent / 180) } + }, [atlas]) + + return ( +
+ onFocus(null)} + > + + { + if (!node) { + setHover(null) + return + } + const rect = wrapRef.current?.getBoundingClientRect() + setHover({ + x: (e.nativeEvent.clientX ?? 0) - (rect?.left ?? 0), + y: (e.nativeEvent.clientY ?? 0) - (rect?.top ?? 0), + text: node.label, + }) + }} + /> + {/* A true 3D galaxy: orbit to rotate, right-drag to pan, scroll to zoom. */} + + + + {hover ? ( +
+ {hover.text} +
+ ) : null} + + {embed ? null : ( + + setHiddenTypes((prev) => { + const next = new Set(prev) + next.has(t) ? next.delete(t) : next.add(t) + return next + }) + } + onToggleLink={(k) => + setHiddenLinks((prev) => { + const next = new Set(prev) + next.has(k) ? next.delete(k) : next.add(k) + return next + }) + } + /> + )} +
+ ) +} + +interface SceneProps { + atlas: AtlasData + bounds: Bounds + hiddenTypes: Set + hiddenLinks: Set + focusedFileId: number | null + onFocus: (fileId: number | null) => void + onHover: (node: AtlasNode | null, e: ThreeEvent) => void +} + +function AtlasScene({ atlas, bounds, hiddenTypes, hiddenLinks, focusedFileId, onFocus, onHover }: SceneProps) { + const { invalidate } = useThree() + + // Picking radius for the Points cloud (world units), scaled to the layout. + const raycaster = useThree((s) => s.raycaster) + useEffect(() => { + raycaster.params.Points = { threshold: bounds.pickThreshold } + }, [raycaster, bounds.pickThreshold]) + + // ---- nodes: one THREE.Points (position + per-point color + size) ---- + const nodeData = useMemo(() => { + const visible = atlas.nodes.filter((n) => !hiddenTypes.has(n.type)) + const count = visible.length + const positions = new Float32Array(count * 3) + const colors = new Float32Array(count * 3) + const sizes = new Float32Array(count) + for (let i = 0; i < count; i++) { + const n = visible[i]! + positions[i * 3] = n.x + positions[i * 3 + 1] = n.y + positions[i * 3 + 2] = n.z + const [r, g, b] = toColor(ATLAS_NODE_COLOR[n.type], n.type === 'file' ? 1.5 : 1.25) + colors[i * 3] = r + colors[i * 3 + 1] = g + colors[i * 3 + 2] = b + sizes[i] = n.radius * 1.1 + } + const geom = new THREE.BufferGeometry() + geom.setAttribute('position', new THREE.BufferAttribute(positions, 3)) + geom.setAttribute('aColor', new THREE.BufferAttribute(colors, 3)) + geom.setAttribute('size', new THREE.BufferAttribute(sizes, 1)) + geom.computeBoundingSphere() + return { geom, index: visible } + }, [atlas, hiddenTypes]) + + const nodeMaterial = useMemo( + () => + new THREE.ShaderMaterial({ + uniforms: { uScale: { value: bounds.dist * 1.3 } }, + vertexShader: NODE_VERT, + fragmentShader: NODE_FRAG, + transparent: true, + blending: THREE.AdditiveBlending, + depthWrite: false, + depthTest: false, + }), + [bounds.dist], + ) + + // ---- edges: one THREE.LineSegments (2 verts/edge, per-vertex color) ---- + const edgeGeom = useMemo(() => { + const posById = new Map() + for (const n of atlas.nodes) posById.set(n.id, n) + const kept: { s: AtlasNode; t: AtlasNode; kind: AtlasLinkKind }[] = [] + for (const l of atlas.links) { + if (hiddenLinks.has(l.kind)) continue + const s = posById.get(l.source) + const t = posById.get(l.target) + if (!s || !t || hiddenTypes.has(s.type) || hiddenTypes.has(t.type)) continue + kept.push({ s, t, kind: l.kind }) + } + const positions = new Float32Array(kept.length * 6) + const colors = new Float32Array(kept.length * 6) + for (let i = 0; i < kept.length; i++) { + const { s, t, kind } = kept[i]! + const intensity = kind === 'imports' ? 0.5 : kind === 'calls' ? 0.6 : 0.18 + const [r, g, b] = toColor(ATLAS_LINK_COLOR[kind], intensity) + const o = i * 6 + positions[o] = s.x + positions[o + 1] = s.y + positions[o + 2] = s.z + positions[o + 3] = t.x + positions[o + 4] = t.y + positions[o + 5] = t.z + colors[o] = r + colors[o + 1] = g + colors[o + 2] = b + colors[o + 3] = r + colors[o + 4] = g + colors[o + 5] = b + } + const geom = new THREE.BufferGeometry() + geom.setAttribute('position', new THREE.BufferAttribute(positions, 3)) + geom.setAttribute('color', new THREE.BufferAttribute(colors, 3)) + return geom + }, [atlas, hiddenLinks, hiddenTypes]) + + const edgeMaterial = useMemo( + () => + new THREE.LineBasicMaterial({ + vertexColors: true, + transparent: true, + opacity: 0.85, + blending: THREE.AdditiveBlending, + depthWrite: false, + depthTest: false, + toneMapped: false, + }), + [], + ) + + // Dispose GPU resources when geometry/material is replaced. + useEffect(() => () => nodeData.geom.dispose(), [nodeData]) + useEffect(() => () => nodeMaterial.dispose(), [nodeMaterial]) + useEffect(() => () => edgeGeom.dispose(), [edgeGeom]) + useEffect(() => () => edgeMaterial.dispose(), [edgeMaterial]) + + // Frame the graph whenever it changes. + const cameraRef = useThree((s) => s.camera) + const controls = useThree((s) => s.controls) as { target: THREE.Vector3; update: () => void } | null + useEffect(() => { + cameraRef.position.set(bounds.cx, bounds.cy - bounds.dist * 0.3, bounds.cz + bounds.dist * 0.92) + cameraRef.far = bounds.dist * 12 + cameraRef.updateProjectionMatrix() + if (controls) { + controls.target.set(bounds.cx, bounds.cy, bounds.cz) + controls.update() + } else { + cameraRef.lookAt(bounds.cx, bounds.cy, bounds.cz) + } + invalidate() + }, [bounds, cameraRef, controls, invalidate]) + + // Focused file node position (for the ring overlay). + const focusPos = useMemo(() => { + if (focusedFileId == null) return null + const n = atlas.nodes.find((nd) => nd.type === 'file' && nd.fileId === focusedFileId) + return n ? ([n.x, n.y, n.z] as [number, number, number]) : null + }, [atlas, focusedFileId]) + + const lastHover = useRef(-1) + + return ( + + + ) => { + e.stopPropagation() + const idx = e.index ?? -1 + if (idx === lastHover.current) return + lastHover.current = idx + onHover(idx >= 0 ? nodeData.index[idx] ?? null : null, e) + }} + onPointerOut={(e: ThreeEvent) => { + lastHover.current = -1 + onHover(null, e) + }} + onClick={(e: ThreeEvent) => { + e.stopPropagation() + const idx = e.index ?? -1 + const node = idx >= 0 ? nodeData.index[idx] : undefined + if (node) onFocus(node.fileId) + }} + /> + {focusPos ? ( + + + + + ) : null} + + ) +} + +function AtlasLegend({ + nodeCounts, + linkCounts, + truncated, + totals, + hiddenTypes, + hiddenLinks, + onToggleType, + onToggleLink, +}: { + nodeCounts: Record + linkCounts: Record + truncated: boolean + totals: { files: number; symbols: number } + hiddenTypes: Set + hiddenLinks: Set + onToggleType: (t: AtlasNodeType) => void + onToggleLink: (k: AtlasLinkKind) => void +}) { + const shownNodes = NODE_TYPES.reduce((sum, t) => sum + nodeCounts[t], 0) + return ( +
+
Nodes
+
+ {NODE_TYPES.map((t) => { + const off = hiddenTypes.has(t) + return ( + + ) + })} +
+
Edges
+
+ {LINK_KINDS.filter((k) => linkCounts[k] > 0).map((k) => { + const off = hiddenLinks.has(k) + return ( + + ) + })} +
+ {truncated ? ( +
+ Showing {shownNodes.toLocaleString()} of {(totals.files + totals.symbols).toLocaleString()} nodes — the + most connected. Capped to keep rendering smooth. +
+ ) : null} +
+ ) +} diff --git a/viewer/src/components/graph/encoding.ts b/viewer/src/components/graph/encoding.ts new file mode 100644 index 0000000..5be9e9b --- /dev/null +++ b/viewer/src/components/graph/encoding.ts @@ -0,0 +1,149 @@ +import type { File, FileHistory, Language } from '@/lib/api/types' + +// Every visual channel encodes a real signal. (CLAUDE.md §9) +// size — LOC +// color — language +// border — hotspot intensity (commits_90d) +// edge — solid=import, dashed=coupling; thickness=weight + +const NODE_W_MIN = 156 +const NODE_W_MAX = 220 +const NODE_H_MIN = 46 +const NODE_H_MAX = 58 + +export function nodeSize(loc: number): { width: number; height: number } { + const t = Math.min(1, Math.sqrt(loc) / Math.sqrt(300)) + return { + width: Math.round(NODE_W_MIN + (NODE_W_MAX - NODE_W_MIN) * t), + height: Math.round(NODE_H_MIN + (NODE_H_MAX - NODE_H_MIN) * t), + } +} + +const LANG_VAR: Record = { + typescript: 'var(--color-lang-typescript)', + javascript: 'var(--color-lang-javascript)', + python: 'var(--color-lang-python)', + go: 'var(--color-lang-go)', + rust: 'var(--color-lang-rust)', + java: 'var(--color-lang-java)', + ruby: 'var(--color-lang-ruby)', + c: 'var(--color-lang-c)', + cpp: 'var(--color-lang-cpp)', + csharp: 'var(--color-lang-csharp)', + php: 'var(--color-lang-php)', + twig: 'var(--color-lang-twig)', + blade: 'var(--color-lang-blade)', + kotlin: 'var(--color-lang-kotlin)', + swift: 'var(--color-lang-swift)', + scala: 'var(--color-lang-scala)', + lua: 'var(--color-lang-lua)', + bash: 'var(--color-lang-bash)', + html: 'var(--color-lang-html)', + css: 'var(--color-lang-css)', + scss: 'var(--color-lang-scss)', + vue: 'var(--color-lang-vue)', + svelte: 'var(--color-lang-svelte)', + dart: 'var(--color-lang-dart)', + elixir: 'var(--color-lang-elixir)', + ocaml: 'var(--color-lang-ocaml)', + solidity: 'var(--color-lang-solidity)', + zig: 'var(--color-lang-zig)', + r: 'var(--color-lang-r)', + elm: 'var(--color-lang-elm)', + rescript: 'var(--color-lang-rescript)', + tlaplus: 'var(--color-lang-tlaplus)', + objc: 'var(--color-lang-objc)', + embedded_template: 'var(--color-lang-embedded_template)', + systemrdl: 'var(--color-lang-systemrdl)', + ql: 'var(--color-lang-ql)', + elisp: 'var(--color-lang-elisp)', + markdown: 'var(--color-lang-markdown)', + json: 'var(--color-lang-json)', + yaml: 'var(--color-lang-yaml)', + toml: 'var(--color-lang-toml)', + sql: 'var(--color-lang-sql)', + graphql: 'var(--color-lang-graphql)', + eda: 'var(--color-lang-eda)', + other: 'var(--color-lang-other)', +} + +export function languageColor(language: Language): string { + return LANG_VAR[language] +} + +export function hotspotBorder(history: FileHistory | undefined): { + width: number + color: string +} { + const c = history?.commits_90d ?? 0 + if (c >= 20) return { width: 3, color: 'var(--color-hot-3)' } + if (c >= 10) return { width: 2, color: 'var(--color-hot-2)' } + if (c >= 4) return { width: 1.5, color: 'var(--color-hot-1)' } + return { width: 1, color: 'var(--color-border)' } +} + +export function shortName(path: string): string { + const ix = path.lastIndexOf('/') + return ix === -1 ? path : path.slice(ix + 1) +} + +export function topLevelFolder(path: string): string { + // Group by two-segment prefix for monorepo layouts (apps/api, apps/web), + // otherwise by the first segment, otherwise '/'. + const segs = path.split('/') + if (segs.length === 1) return '/' + if (segs.length >= 2 && (segs[0] === 'apps' || segs[0] === 'packages' || segs[0] === 'services')) { + return `${segs[0]}/${segs[1]}` + } + return segs[0] ?? '/' +} + +export function folderLabel(folder: string): string { + if (folder === '/') return 'root' + return folder +} + +export function isRecentlyChanged(file: File, history: FileHistory | undefined): boolean { + if (!history) return false + return history.commits_30d > 0 +} + +// Folder pastel + bold pair — pastel for the container background, bold for +// the folder-name tag tab. Indices in the two arrays match so a folder gets +// a harmonious pair. +const PASTEL_TINTS = [ + '#FFE5D6', // soft peach + '#D6ECFF', // soft sky + '#E2F3D6', // soft mint + '#F0E2FF', // soft lavender + '#FFF5C8', // soft butter + '#FFD9E1', // soft rose + '#D6F2EC', // soft seafoam +] + +const BOLD_TINTS = [ + '#EA580C', // orange + '#0284C7', // sky + '#16A34A', // green + '#7C3AED', // violet + '#CA8A04', // amber + '#DB2777', // pink + '#0D9488', // teal +] + +function hashFolder(folder: string): number { + let h = 0 + for (let i = 0; i < folder.length; i++) { + h = ((h << 5) - h) + folder.charCodeAt(i) + h |= 0 + } + return Math.abs(h) +} + +export function folderTint(folder: string): string { + return PASTEL_TINTS[hashFolder(folder) % PASTEL_TINTS.length] ?? PASTEL_TINTS[0] ?? '#FAFAFA' +} + +export function folderTab(folder: string): string { + return BOLD_TINTS[hashFolder(folder) % BOLD_TINTS.length] ?? BOLD_TINTS[0] ?? '#0a0a0a' +} diff --git a/viewer/src/lib/api/types.ts b/viewer/src/lib/api/types.ts new file mode 100644 index 0000000..8194e25 --- /dev/null +++ b/viewer/src/lib/api/types.ts @@ -0,0 +1,308 @@ +import { z } from 'zod' + +// ---------- Core data model (mirrors CLAUDE.md §4) ---------- + +export const LanguageSchema = z.enum([ + 'typescript', + 'javascript', + 'python', + 'go', + 'rust', + 'java', + 'ruby', + 'c', + 'cpp', + 'csharp', + 'php', + 'twig', + 'blade', + 'kotlin', + 'swift', + 'scala', + 'lua', + 'bash', + 'html', + 'css', + 'scss', + 'vue', + 'svelte', + 'dart', + 'elixir', + 'ocaml', + 'solidity', + 'zig', + 'r', + 'elm', + 'rescript', + 'tlaplus', + 'objc', + 'embedded_template', + 'systemrdl', + 'ql', + 'elisp', + 'json', + 'yaml', + 'toml', + 'markdown', + 'sql', + 'graphql', + 'eda', + 'other', +]) +export type Language = z.infer + +export const FileSchema = z.object({ + id: z.number(), + repo_id: z.string(), + path: z.string(), + // .catch keeps an unrecognised language (e.g. one the engine learns before the + // UI does) from failing the whole graph parse — it just renders as 'other'. + language: LanguageSchema.catch('other'), + loc: z.number(), + sha: z.string(), + last_modified: z.number(), +}) +export type File = z.infer + +export const SymbolKindSchema = z.enum(['function', 'class', 'const', 'type', 'interface']) +export type SymbolKind = z.infer + +export const SymbolSchema = z.object({ + id: z.number(), + file_id: z.number(), + name: z.string(), + kind: SymbolKindSchema, + start_line: z.number(), + end_line: z.number(), + is_exported: z.boolean(), +}) +export type Symbol = z.infer + +export const EdgeKindSchema = z.enum(['import', 'call', 'extends', 'implements']) +export type EdgeKind = z.infer + +export const NodeKindSchema = z.enum(['file', 'symbol']) +export type NodeKind = z.infer + +export const EdgeSchema = z.object({ + id: z.number(), + source_id: z.number(), + target_id: z.number(), + source_kind: NodeKindSchema, + target_kind: NodeKindSchema, + edge_kind: EdgeKindSchema, + weight: z.number().default(1), +}) +export type Edge = z.infer + +export const FileHistorySchema = z.object({ + file_id: z.number(), + commits_30d: z.number(), + commits_90d: z.number(), + authors_90d: z.number(), +}) +export type FileHistory = z.infer + +export const CouplingSchema = z.object({ + file_a_id: z.number(), + file_b_id: z.number(), + co_changes: z.number(), +}) +export type Coupling = z.infer + +// ---------- Repo summary (frontend-only convenience) ---------- + +export const RepoSummarySchema = z.object({ + repo_id: z.string(), + name: z.string(), + root_path: z.string(), + file_count: z.number(), + total_loc: z.number(), + languages: z.array(z.object({ language: LanguageSchema.catch('other'), file_count: z.number() })), + indexed_at: z.number(), +}) +export type RepoSummary = z.infer + +// ---------- /index — indexing job ---------- + +export const IndexStageSchema = z.enum([ + 'walk', + 'parse', + 'resolve', + 'graph', + 'git', + 'chunk', + 'embed', + 'store', + 'ready', +]) +export type IndexStage = z.infer + +export const IndexJobSchema = z.object({ + job_id: z.string(), + repo_id: z.string(), + status: z.enum(['queued', 'running', 'complete', 'failed']), + stage: IndexStageSchema, + progress: z.number().min(0).max(1), + message: z.string().optional(), + error: z.string().optional(), +}) +export type IndexJob = z.infer + +// ---------- /graph — graph slice ---------- + +export const GraphScopeSchema = z.object({ + kind: z.enum(['all', 'folder', 'file']), + value: z.string().optional(), + depth: z.number().int().min(1).max(5).default(2), +}) +export type GraphScope = z.infer + +export const LayoutNodeSchema = z.object({ id: z.number(), x: z.number(), y: z.number() }) +export type LayoutNode = z.infer + +export const GraphResponseSchema = z.object({ + repo: RepoSummarySchema, + files: z.array(FileSchema), + symbols: z.array(SymbolSchema), + edges: z.array(EdgeSchema), + history: z.array(FileHistorySchema).default([]), + coupling: z.array(CouplingSchema).default([]), + // Precomputed deterministic 2D positions (file id → x,y) from the engine. The + // viewer renders these directly instead of running a client-side force sim. + layout: z.object({ nodes: z.array(LayoutNodeSchema) }).optional(), +}) +export type GraphResponse = z.infer + +// ---------- Group graph (architecture view) ---------- +// Higher-level lens: every folder/module is a single node, edges are the +// aggregated imports between modules. + +export const GroupNodeSchema = z.object({ + id: z.string(), + label: z.string(), + description: z.string().default(''), + type: z.literal('group'), + memberCount: z.number(), + members: z.array(z.string()).default([]), + size: z.number().default(100), + color: z.string().default('gray'), +}) +export type GroupNode = z.infer + +export const GroupEdgeSchema = z.object({ + id: z.string(), + source: z.string(), + target: z.string(), + type: z.literal('imports'), + label: z.string().default(''), + importCount: z.number().default(0), + importedProperties: z.array(z.string()).default([]), +}) +export type GroupEdge = z.infer + +export const GroupGraphResponseSchema = z.object({ + elements: z.object({ + nodes: z.array(z.object({ data: GroupNodeSchema })), + edges: z.array(z.object({ data: GroupEdgeSchema })), + }), + summary: z.string().default(''), +}) +export type GroupGraphResponse = z.infer + +// ---------- /search — semantic search ---------- + +export const SearchHitSchema = z.object({ + id: z.string(), + file_path: z.string(), + start_line: z.number(), + end_line: z.number(), + symbol_name: z.string().nullable(), + snippet: z.string(), + score: z.number(), +}) +export type SearchHit = z.infer + +export const SearchResponseSchema = z.object({ + query: z.string(), + hits: z.array(SearchHitSchema), +}) +export type SearchResponse = z.infer + +// ---------- /explain — narrator stream ---------- + +export const CitationSchema = z.object({ + file_path: z.string(), + start_line: z.number(), + end_line: z.number(), + symbol_name: z.string().nullable(), +}) +export type Citation = z.infer + +export type ExplainEvent = + | { type: 'token'; delta: string } + | { type: 'citation'; index: number; citation: Citation } + | { type: 'done' } + | { type: 'error'; error: string } + +export const ExplainRequestSchema = z.object({ + repo_id: z.string(), + message: z.string(), + focused_path: z.string().nullable(), + selected_paths: z.array(z.string()).default([]), +}) +export type ExplainRequest = z.infer + +// ---------- /ask — structured feature breakdown ---------- + +export const AskModeSchema = z.enum(['explain', 'trace', 'deep']) +export type AskMode = z.infer + +export const AskFileRoleSchema = z.enum([ + 'entry-point', + 'implementation', + 'config', + 'data', + 'ui', + 'test', + 'support', +]) +export type AskFileRole = z.infer + +export const AskFileSchema = z.object({ + path: z.string(), + role: AskFileRoleSchema, + note: z.string().optional(), +}) +export type AskFile = z.infer + +export const AskFolderSchema = z.object({ + name: z.string(), + note: z.string().optional(), +}) +export type AskFolder = z.infer + +export const AskEdgeSchema = z.object({ + from: z.string(), + to: z.string(), + reason: z.string().optional(), +}) +export type AskEdge = z.infer + +export const AskResultSchema = z.object({ + summary: z.string(), + explanation: z.string(), + files: z.array(AskFileSchema), + folders: z.array(AskFolderSchema), + edges: z.array(AskEdgeSchema), + agentPrompt: z.string(), + ai: z.boolean(), +}) +export type AskResult = z.infer + +export const AskRequestSchema = z.object({ + repo_id: z.string(), + query: z.string(), + mode: AskModeSchema.default('explain'), +}) +export type AskRequest = z.infer diff --git a/viewer/src/lib/graph/atlas.ts b/viewer/src/lib/graph/atlas.ts new file mode 100644 index 0000000..31e9b9f --- /dev/null +++ b/viewer/src/lib/graph/atlas.ts @@ -0,0 +1,209 @@ +// Assemble the "Atlas": the whole codebase as one interconnected node-link +// graph — files + symbols (functions/classes/types/interfaces/consts) wired by +// `defines` (file → symbol) and `imports` (file → file). Node POSITIONS are the +// engine's deterministic ring-by-folder + Barnes-Hut layout (graph.layout); the +// viewer renders them directly — no client-side force simulation. Heuristic +// `calls` (function → function) land here once the core engine extracts them. + +import type { GraphResponse } from '@/lib/api/types' +import { shortName } from '@/components/graph/encoding' + +export type AtlasNodeType = 'file' | 'function' | 'class' | 'interface' | 'type' | 'const' +export type AtlasLinkKind = 'imports' | 'defines' | 'calls' + +export interface AtlasNode { + id: string + label: string + type: AtlasNodeType + /** The owning file id (for click-to-focus + filtering). */ + fileId: number + /** Render radius (files scale with LOC; symbols are small). */ + radius: number + /** Precomputed world position — the viewer renders this, no client sim. */ + x: number + y: number + /** Depth — the atlas is a true 3D galaxy: files dome up from the centre and + * symbols orbit their file on a 3D shell. */ + z: number +} + +export interface AtlasLink { + source: string + target: string + kind: AtlasLinkKind +} + +export interface AtlasData { + nodes: AtlasNode[] + links: AtlasLink[] + nodeCounts: Record + linkCounts: Record + /** True when the repo exceeded the node budget and we rendered a subset. */ + truncated: boolean + /** Total file/symbol counts in the source graph, before any capping. */ + totals: { files: number; symbols: number } +} + +/** Color per node type — mirrors the codebase-memory legend palette. */ +export const ATLAS_NODE_COLOR: Record = { + file: '#3b82f6', // blue + function: '#22d3ee', // cyan + class: '#a855f7', // purple + interface: '#c084fc', // violet + type: '#94a3b8', // slate + const: '#64748b', // dim slate +} + +export const ATLAS_LINK_COLOR: Record = { + imports: '#3b82f6', + defines: '#475569', + calls: '#f59e0b', // amber (matches the spotlight convention) +} + +export function buildAtlas(graph: GraphResponse): AtlasData { + const nodes: AtlasNode[] = [] + const links: AtlasLink[] = [] + const nodeCounts: Record = { + file: 0, + function: 0, + class: 0, + interface: 0, + type: 0, + const: 0, + } + const linkCounts: Record = { imports: 0, defines: 0, calls: 0 } + + const fileNodeId = (id: number) => `f${id}` + const symNodeId = (id: number) => `s${id}` + + // Every node shows up — no capping. The viewer draws the whole codebase with + // WebGL instancing (one Points draw call + one LineSegments draw call), so even + // large repos render as one galaxy. + const files = graph.files + const fileIds = new Set(files.map((f) => f.id)) + const symbols = graph.symbols.filter((s) => fileIds.has(s.file_id)) + + // File positions come from the engine's deterministic layout (ring-by-folder + + // Barnes-Hut). Fall back to a golden-angle spiral if no layout was supplied. + const layoutPos = new Map() + for (const ln of graph.layout?.nodes ?? []) layoutPos.set(ln.id, { x: ln.x, y: ln.y }) + const filePos = new Map() + files.forEach((f, i) => { + const p = layoutPos.get(f.id) + if (p) filePos.set(f.id, p) + else { + const a = i * 2.399963 // golden angle + const r = 40 * Math.sqrt(i + 1) + filePos.set(f.id, { x: Math.cos(a) * r, y: Math.sin(a) * r }) + } + }) + + // ---- Lift the flat layout into 3D. Files dome up from the centroid (central, + // load-bearing files rise toward the viewer) with a little deterministic jitter + // for volume; symbols then orbit their file on a 3D shell. Deterministic — same + // graph, same galaxy. ---- + let cx = 0 + let cy = 0 + for (const p of filePos.values()) { + cx += p.x + cy += p.y + } + const nf = filePos.size || 1 + cx /= nf + cy /= nf + let maxR = 1 + for (const p of filePos.values()) { + const d = Math.hypot(p.x - cx, p.y - cy) + if (d > maxR) maxR = d + } + // Deterministic hash → [-1, 1] from an integer id (no Math.random, so reloads + // don't reshuffle the galaxy). + const jitter = (n: number) => { + let h = (n * 2654435761) >>> 0 + h ^= h >>> 15 + h = (h * 2246822519) >>> 0 + h ^= h >>> 13 + return ((h >>> 0) / 4294967295) * 2 - 1 + } + const Z_BULGE = maxR * 0.7 + const fileZ = new Map() + for (const f of files) { + const p = filePos.get(f.id)! + const rr = Math.hypot(p.x - cx, p.y - cy) / maxR + fileZ.set(f.id, Z_BULGE * (1 - rr * rr) + jitter(f.id) * maxR * 0.14) + } + + for (const f of files) { + const p = filePos.get(f.id)! + nodes.push({ + id: fileNodeId(f.id), + label: shortName(f.path), + type: 'file', + fileId: f.id, + radius: Math.max(3, Math.min(9, 3 + Math.sqrt(f.loc) / 6)), + x: p.x, + y: p.y, + z: fileZ.get(f.id) ?? 0, + }) + nodeCounts.file++ + } + + // Symbols orbit their owning file on a 3D shell (golden-angle azimuth × evenly + // sliced inclination) — deterministic, and tight enough to stay near the file. + const symOrbit = new Map() + const symIds = new Set() + for (const s of symbols) { + if (!fileIds.has(s.file_id)) continue + const type = s.kind as AtlasNodeType + if (!(type in nodeCounts)) continue + const fp = filePos.get(s.file_id)! + const fz = fileZ.get(s.file_id) ?? 0 + const k = symOrbit.get(s.file_id) ?? 0 + symOrbit.set(s.file_id, k + 1) + const ang = k * 2.399963 // golden angle azimuth + const zoff = ((k % 16) / 15) * 2 - 1 // -1..1 inclination band + const ring = Math.sqrt(Math.max(0, 1 - zoff * zoff)) + const orad = 7 + k * 1.1 + nodes.push({ + id: symNodeId(s.id), + label: s.name, + type, + fileId: s.file_id, + radius: 2.4, + x: fp.x + Math.cos(ang) * ring * orad, + y: fp.y + Math.sin(ang) * ring * orad, + z: fz + zoff * orad, + }) + symIds.add(s.id) + nodeCounts[type]++ + // defines: the file → the symbol it declares. + links.push({ source: fileNodeId(s.file_id), target: symNodeId(s.id), kind: 'defines' }) + linkCounts.defines++ + } + + // Nothing is dropped any more. + const truncated = false + + for (const e of graph.edges) { + if (e.edge_kind === 'import' && e.source_kind === 'file' && e.target_kind === 'file') { + if (!fileIds.has(e.source_id) || !fileIds.has(e.target_id)) continue + links.push({ source: fileNodeId(e.source_id), target: fileNodeId(e.target_id), kind: 'imports' }) + linkCounts.imports++ + } else if (e.edge_kind === 'call' && e.source_kind === 'symbol' && e.target_kind === 'symbol') { + // Only if both symbol nodes are present (they may be dropped under the + // exported-only cap on big repos). + if (!symIds.has(e.source_id) || !symIds.has(e.target_id)) continue + links.push({ source: symNodeId(e.source_id), target: symNodeId(e.target_id), kind: 'calls' }) + linkCounts.calls++ + } + } + + return { + nodes, + links, + nodeCounts, + linkCounts, + truncated, + totals: { files: graph.files.length, symbols: graph.symbols.length }, + } +} diff --git a/viewer/src/lib/graph/capGraph.ts b/viewer/src/lib/graph/capGraph.ts new file mode 100644 index 0000000..d6eadb3 --- /dev/null +++ b/viewer/src/lib/graph/capGraph.ts @@ -0,0 +1,55 @@ +// Bound a file-level GraphResponse to a maximum node count before it is laid out +// and rendered. The file graph (React Flow) mounts one DOM node per file + edge, +// so an uncapped graph on a large repo blows up RAM. When over budget we keep the +// most import-connected files (the backbone), tie-broken by LOC, and drop the +// long tail — mirroring the node-budget approach in `lib/graph/atlas.ts`. + +import type { GraphResponse } from '@/lib/api/types' + +export interface CappedGraph { + graph: GraphResponse + truncated: boolean + shownFiles: number + totalFiles: number +} + +export function capFileGraph(graph: GraphResponse, cap: number): CappedGraph { + const totalFiles = graph.files.length + if (totalFiles <= cap) { + return { graph, truncated: false, shownFiles: totalFiles, totalFiles } + } + + // Rank files by import-degree (how connected they are), tie-broken by size. + const degree = new Map() + for (const e of graph.edges) { + if (e.edge_kind === 'import' && e.source_kind === 'file' && e.target_kind === 'file') { + degree.set(e.source_id, (degree.get(e.source_id) ?? 0) + 1) + degree.set(e.target_id, (degree.get(e.target_id) ?? 0) + 1) + } + } + const kept = [...graph.files] + .sort((a, b) => (degree.get(b.id) ?? 0) - (degree.get(a.id) ?? 0) || b.loc - a.loc) + .slice(0, cap) + const keepIds = new Set(kept.map((f) => f.id)) + + // Symbol → owning file, so we can drop symbol-level edges whose file was cut. + const fileOfSymbol = new Map() + for (const s of graph.symbols) fileOfSymbol.set(s.id, s.file_id) + const symbolKept = (id: number) => { + const fid = fileOfSymbol.get(id) + return fid !== undefined && keepIds.has(fid) + } + const endpointKept = (id: number, kind: 'file' | 'symbol') => + kind === 'file' ? keepIds.has(id) : symbolKept(id) + + const graphOut: GraphResponse = { + ...graph, + files: kept, + symbols: graph.symbols.filter((s) => keepIds.has(s.file_id)), + edges: graph.edges.filter( + (e) => endpointKept(e.source_id, e.source_kind) && endpointKept(e.target_id, e.target_kind), + ), + history: graph.history.filter((h) => keepIds.has(h.file_id)), + } + return { graph: graphOut, truncated: true, shownFiles: kept.length, totalFiles } +} diff --git a/viewer/src/lib/utils.ts b/viewer/src/lib/utils.ts new file mode 100644 index 0000000..b057456 --- /dev/null +++ b/viewer/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from 'clsx' +import { twMerge } from 'tailwind-merge' + +export function cn(...inputs: ClassValue[]): string { + return twMerge(clsx(inputs)) +} diff --git a/viewer/src/lib/utils/keyboardNav.ts b/viewer/src/lib/utils/keyboardNav.ts new file mode 100644 index 0000000..674e083 --- /dev/null +++ b/viewer/src/lib/utils/keyboardNav.ts @@ -0,0 +1,14 @@ +// Tiny shared keyboard-nav helpers used by the graph / group / city views. +// The big rule: don't hijack arrows or +/- when the user is typing in a +// composer, the voice picker, or any contenteditable region. + +export function isTypingTarget(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false + const tag = target.tagName.toLowerCase() + if (tag === 'input' || tag === 'textarea' || tag === 'select') return true + if (target.isContentEditable) return true + // React Flow puts its zoom slider on `input[type=range]`, which is already + // caught above, but the modal/portal-mounted ones bypass tagName checks. + if (target.getAttribute('role') === 'textbox') return true + return false +} diff --git a/viewer/src/main.tsx b/viewer/src/main.tsx new file mode 100644 index 0000000..ec84a09 --- /dev/null +++ b/viewer/src/main.tsx @@ -0,0 +1,7 @@ +import { createRoot } from 'react-dom/client' +import './styles.css' +import { App } from './App' + +// No StrictMode: it double-invokes effects in dev, which would spin up two +// EventSource/WebGL contexts. The build is the product here, not a dev session. +createRoot(document.getElementById('root')!).render() diff --git a/viewer/src/styles.css b/viewer/src/styles.css new file mode 100644 index 0000000..db7016f --- /dev/null +++ b/viewer/src/styles.css @@ -0,0 +1,143 @@ +@import "tailwindcss"; + +@theme { + --color-ink-0: #ffffff; + --color-ink-1: #fafafa; + --color-ink-2: #f5f5f5; + --color-ink-3: #ebebeb; + --color-ink-4: #d4d4d4; + --color-ink-5: #a3a3a3; + --color-ink-6: #6b6b6b; + --color-ink-7: #2a2a2a; + --color-ink-8: #0a0a0a; + + /* Aliases — old token names map to ink-N so component code stays put. */ + --color-bg: #fafafa; + --color-bg-elev: #ffffff; + --color-fg: #0a0a0a; + --color-muted: #6b6b6b; + --color-border: #ebebeb; + --color-accent: #0a0a0a; + + /* Language palette — still semantic (color carries signal per §9). */ + --color-lang-typescript: #3178C6; + --color-lang-javascript: #F7DF1E; + --color-lang-python: #3776AB; + --color-lang-go: #00ADD8; + --color-lang-rust: #CE422B; + --color-lang-java: #E76F00; + --color-lang-ruby: #CC342D; + --color-lang-c: #6E7781; + --color-lang-cpp: #F34B7D; + --color-lang-csharp: #178600; + --color-lang-php: #4F5D95; + --color-lang-twig: #9BB52C; + --color-lang-blade: #F7523F; + --color-lang-kotlin: #A97BFF; + --color-lang-swift: #F05138; + --color-lang-scala: #C22D40; + --color-lang-lua: #5B7FE0; + --color-lang-bash: #4EAA25; + --color-lang-html: #E34C26; + --color-lang-css: #663399; + --color-lang-scss: #C6538C; + --color-lang-vue: #41B883; + --color-lang-svelte: #FF3E00; + --color-lang-dart: #00B4AB; + --color-lang-elixir: #6E4A7E; + --color-lang-ocaml: #EF7A08; + --color-lang-solidity: #AA6746; + --color-lang-zig: #EC915C; + --color-lang-r: #276DC3; + --color-lang-elm: #60B5CC; + --color-lang-rescript: #E6484F; + --color-lang-tlaplus: #4E4E4E; + --color-lang-objc: #438EFF; + --color-lang-embedded_template: #B0AC9F; + --color-lang-systemrdl: #8A8A8A; + --color-lang-ql: #FF6600; + --color-lang-elisp: #7F5AB6; + --color-lang-markdown: #6B7280; + --color-lang-json: #9CA3AF; + --color-lang-yaml: #CB171E; + --color-lang-toml: #9C4221; + --color-lang-sql: #E38C00; + --color-lang-graphql: #E10098; + --color-lang-eda: #B87333; /* copper — PCB traces (KiCad/EAGLE/Gerber) */ + --color-lang-other: #9CA3AF; + + /* Hotspot encoding — still semantic. */ + --color-hot-1: #FBBF24; + --color-hot-2: #F59E0B; + --color-hot-3: #EF4444; + + /* Landing palette — warm paper neutrals + a single blue family. */ + --color-lp-beige: #f1f0eb; + --color-lp-duck: #f1f5f8; + --color-lp-sky: #d3e7ff; + --color-lp-blue: #628ad1; + --color-lp-steel: #3d5e86; + --color-lp-navy: #29313f; + --color-lp-ink: #1b1b1b; + --color-lp-grey: #7d7d7d; + --color-lp-line: #e3e1da; + + /* "Paper & coal" palette — editorial light + engineering dark + forest accent. */ + --color-o-paper: #f6f5f1; + --color-o-ink: #161616; + --color-o-mut: #6e6e68; + --color-o-line: #e7e5df; + --color-o-forest: #213529; + --color-o-leaf: #44694f; + --color-o-coal: #0a0a0a; + --color-o-coal2: #141414; + --color-o-cline: #242424; + --color-o-cmut: #93938d; + + --font-sans: "Google Sans", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif; + --font-mono: var(--font-jetbrains), ui-monospace, "SF Mono", Menlo, Monaco, "Cascadia Mono", monospace; + --font-display: var(--font-geist), "Geist", "Google Sans", ui-sans-serif, system-ui, sans-serif; + --font-serif: var(--font-instrument), "Instrument Serif", Georgia, "Times New Roman", serif; +} + +html, body { + margin: 0; + padding: 0; + background: var(--color-ink-0); + color: var(--color-ink-8); + font-family: var(--font-sans); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +* { + box-sizing: border-box; +} + +/* Dark theme tokens. Applied two ways: + • html.theme-dark — the app-wide dark mode (toggled in the top bar, persisted). + • .atlas-dark — the atlas always darkens its chrome, even in light mode. + Only the neutral chrome tokens flip; language + hotspot hues stay true. */ +.theme-dark, +.atlas-dark { + --color-ink-0: #07080c; + --color-ink-1: #0d0f15; + --color-ink-2: #14161e; + --color-ink-3: #1c1f29; + --color-ink-4: #2a2e3b; + --color-ink-5: #6b7280; + --color-ink-6: #9aa3b2; + --color-ink-7: #c8cfdb; + --color-ink-8: #f2f5fa; + + --color-bg: #07080c; + --color-bg-elev: #0e1016; + --color-fg: #f2f5fa; + --color-muted: #8b93a3; + --color-border: #1c1f29; + /* The brand amber, not light — so the many `bg-accent text-white` buttons stay + readable AND `text-accent` emphasis still pops against the dark. */ + --color-accent: #d2722e; +} + +/* Tight uppercase mono section label — used throughout the chrome. */ diff --git a/viewer/tsconfig.json b/viewer/tsconfig.json new file mode 100644 index 0000000..ce6ce86 --- /dev/null +++ b/viewer/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "moduleResolution": "bundler", + "jsx": "react-jsx", + "strict": true, + "noEmit": true, + "skipLibCheck": true, + "esModuleInterop": true, + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "baseUrl": ".", + "paths": { "@/*": ["src/*"] } + }, + "include": ["src", "vite.config.ts"] +} diff --git a/viewer/vite.config.ts b/viewer/vite.config.ts new file mode 100644 index 0000000..dcec856 --- /dev/null +++ b/viewer/vite.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' +import * as path from 'node:path' + +// Standalone SPA build. The `@` alias mirrors the app's alias so the Atlas/City +// source files are copied in VERBATIM (no import rewriting). Output is a +// self-contained dist/ (single JS+CSS bundle) served by `openvisio view`. +export default defineConfig({ + plugins: [react(), tailwindcss()], + resolve: { + alias: { '@': path.resolve(__dirname, 'src') }, + }, + base: './', // assets referenced relative, so the static server can host at / + build: { + outDir: 'dist', + chunkSizeWarningLimit: 4000, // three.js is large; this is expected + }, +})