From 90904fbce9a3006c9d1e12ecec551b3299aa453d Mon Sep 17 00:00:00 2001 From: Daniel Adewale Date: Sun, 21 Jun 2026 14:41:02 +0100 Subject: [PATCH] feat (build): atlas/city view. --- README.md | 80 +- core/src/parse/grammars/dart.ts | 8 +- core/src/parse/grammars/r.ts | 10 +- core/wasm/tree-sitter-r.wasm | Bin 0 -> 487039 bytes mcp/README.md | 17 +- mcp/build.mjs | 8 +- mcp/package.json | 3 +- mcp/src/server.ts | 2 +- mcp/src/viewer.ts | 44 +- mcp/viewer/app.js | 466 ---- mcp/viewer/index.html | 48 - mcp/viewer/style.css | 135 - package-lock.json | 2902 ++++++++++++++++++++- package.json | 5 +- viewer/index.html | 13 + viewer/package.json | 36 + viewer/src/App.tsx | 146 ++ viewer/src/components/city/CityView.tsx | 1475 +++++++++++ viewer/src/components/city/cityLayout.ts | 416 +++ viewer/src/components/city/treemap.ts | 97 + viewer/src/components/graph/AtlasView.tsx | 446 ++++ viewer/src/components/graph/encoding.ts | 149 ++ viewer/src/lib/api/types.ts | 308 +++ viewer/src/lib/graph/atlas.ts | 209 ++ viewer/src/lib/graph/capGraph.ts | 55 + viewer/src/lib/utils.ts | 6 + viewer/src/lib/utils/keyboardNav.ts | 14 + viewer/src/main.tsx | 7 + viewer/src/styles.css | 143 + viewer/tsconfig.json | 19 + viewer/vite.config.ts | 19 + 31 files changed, 6469 insertions(+), 817 deletions(-) create mode 100644 core/wasm/tree-sitter-r.wasm delete mode 100644 mcp/viewer/app.js delete mode 100644 mcp/viewer/index.html delete mode 100644 mcp/viewer/style.css create mode 100644 viewer/index.html create mode 100644 viewer/package.json create mode 100644 viewer/src/App.tsx create mode 100644 viewer/src/components/city/CityView.tsx create mode 100644 viewer/src/components/city/cityLayout.ts create mode 100644 viewer/src/components/city/treemap.ts create mode 100644 viewer/src/components/graph/AtlasView.tsx create mode 100644 viewer/src/components/graph/encoding.ts create mode 100644 viewer/src/lib/api/types.ts create mode 100644 viewer/src/lib/graph/atlas.ts create mode 100644 viewer/src/lib/graph/capGraph.ts create mode 100644 viewer/src/lib/utils.ts create mode 100644 viewer/src/lib/utils/keyboardNav.ts create mode 100644 viewer/src/main.tsx create mode 100644 viewer/src/styles.css create mode 100644 viewer/tsconfig.json create mode 100644 viewer/vite.config.ts 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 0000000000000000000000000000000000000000..4ce49a987b607a3491f7943b25d70efbb09ee393 GIT binary patch literal 487039 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 { 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 + }, +})