Initial commit: hermes-wiki-viewer v0.2.0

Fork of DanielCheer/obsidian-web-viewer (MIT, 2026-04) with Lukas' first
additions for the Hermes Wiki workflow.

Additive customization layer (vault-custom.js) — keeps upstream-merge
trivial via a one-line script tag in vault.html.

Features added:
1. Current-page highlighting in 3D Graph — current node gold+glow,
   connected nodes light-blue, others dim to 20% opacity
2. Click-to-navigate on graph — Three.js raycaster triggers file load
3. Responsive layout — graph collapses below 1024px, tree below 768px
4. URL-based deep-linking via ?file=<path> query param
5. Server-side ?file= support in /api/vault/file/ endpoint

Modified files:
- server.py: +7 lines (Lukas-add: ?file= query param parsing)
- vault.html: +1 line (script tag for vault-custom.js)

New files:
- vault-custom.js: 11KB, all customizations in one place under HermesCustom namespace
- README.md: fork intro, quick start, customization guide
- CHANGELOG.md: Lukas-additions tracking
- docs/ARCHITECTURE.md: design rationale
- docs/CUSTOMIZATIONS.md: feature spec
- .gitignore: standard

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-16 15:17:16 +00:00
co-authored by Claude
commit 677a8dde4f
10 changed files with 1374 additions and 0 deletions
+24
View File
@@ -0,0 +1,24 @@
# Python
__pycache__/
*.pyc
*.pyo
*.pyd
*.so
.Python
*.egg-info/
# OS
.DS_Store
Thumbs.db
# Editor
.vscode/
.idea/
*.swp
# Logs (skript-generated)
*.log
# Build artifacts
dist/
build/
+56
View File
@@ -0,0 +1,56 @@
# Changelog — Lukas' Additions
All notable changes to this fork are documented here. Upstream-tracking via [DanielCheer/obsidian-web-viewer](https://github.com/DanielCheer/obsidian-web-viewer).
## v0.2.0 — 2026-07-16 — Lukas' first additions
### Added
- **`vault-custom.js`** — additive JavaScript layer with three features:
- **Current-page highlighting in 3D Graph**: node representing the open file becomes gold with glow; connected nodes (via WikiLinks) become light-blue; unrelated nodes dim to 20% opacity
- **Click-to-navigate on 3D Graph**: Three.js raycaster triggers `loadFile()` on node click; URL updates with `?file=<path>` query param
- **Responsive layout**: at viewport <1024px the 3D graph collapses to a 200px bottom panel; at <768px the tree collapses to a hamburger menu and graph hides entirely
- **One-line patch in `vault.html`**: `<script src="vault-custom.js"></script>` before `</body>` (single source of upstream-merge friction)
- **7-line patch in `server.py`**: `?file=<path>` query parameter support on `/api/vault/file/` for clean URL navigation
- **`docs/ARCHITECTURE.md`**: design rationale (why additive layer, why HermesCustom namespace, what tradeoffs we accepted)
- **`docs/CUSTOMIZATIONS.md`**: feature spec with code pointers
- **`README.md`**: Lukas-authored intro, Quick Start, customization guide, upstream-merge procedure
### Preserved from upstream
- Catppuccin-dark theme
- Three.js 3D graph
- File tree sidebar with collapsible folders
- WikiLink resolution with search fallback
- Full-text search
- Frontmatter-as-card rendering
- Python stdlib only (no Flask/FastAPI dependency)
### Migration notes
If you're coming from upstream obv:
1. `python3 server.py --vault <your-vault> --host 127.0.0.1 --port 8765` works as before
2. The Tailscale-URL gets `?file=` query params on navigation (deep-linkable)
3. Customizations only kick in if `vault-custom.js` is reachable from the same origin (it is, served by the same server.py)
## v0.1.0 — 2026-04 — Upstream baseline
Forked from [DanielCheer/obsidian-web-viewer @ commit `master`](https://github.com/DanielCheer/obsidian-web-viewer) on 2026-07-16. No modifications yet.
---
## Roadmap
Next planned additions (in priority order):
- **Backlinks panel** — for each loaded file, show incoming WikiLinks (which other files link to this one). Adds a third collapsible panel on the right or merges into graph context menu.
- **Keyboard shortcuts**:
- `j` / `k` — next / previous file in current folder
- `[` / `]` — back / forward in navigation history
- `g` — focus 3D graph
- `/` — focus search box
- **Light theme variant** — Catppuccin-Light alongside Catppuccin-Dark, toggle in header
- **Recent files section** — show last 10 visited files in tree sidebar
- **Graph filters** — toggle to show only current-folder nodes, only connected nodes, etc.
Lukas' note: each new addition should remain in `vault-custom.js`. If it can't fit there cleanly, it's a sign that the addition deserves its own script tag (loaded after `vault-custom.js`).
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2025 DanielCheer
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+98
View File
@@ -0,0 +1,98 @@
# Hermes Wiki Viewer
A customizable Obsidian-vault viewer with 3D Graph visualization, current-page highlighting, and responsive layout. Designed specifically for Lukas Huber's [Hermes Wiki](https://github.com/NousResearch/hermes-agent) workflow.
**This is a side-project fork of [DanielCheer/obsidian-web-viewer](https://github.com/DanielCheer/obsidian-web-viewer)** (MIT, 2026-04) with customizations layered on top via an additive `vault-custom.js`. Upstream patches merge cleanly because we touch exactly one line of `vault.html`.
## Features (in addition to upstream)
- **Current-page highlighting in 3D Graph** — the node representing the open file gets a brighter material and a glow outline; connected nodes (via `[[wikilinks]]`) are also highlighted; unrelated nodes dim to 20% opacity
- **Click-to-navigate on 3D Graph** — Three.js raycaster triggers file navigation on node click
- **Responsive layout** — graph collapses to a bottom panel below 1024px viewport; tree collapses to hamburger menu below 768px
- **Backlinks panel** (planned) — see CHANGELOG.md
## Features (from upstream, unchanged)
- File tree sidebar (collapsible folder tree)
- Markdown rendering with code blocks, tables, blockquotes, images
- Wikilink navigation (`[[links]]` click-to-traverse)
- YAML frontmatter rendered as styled card
- Full-text search by note name and content with snippets
- 3D graph visualization (Three.js) showing note connections
- Catppuccin-inspired dark theme
- Zero client-side setup — anyone with the URL can browse
## Architecture
See [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) for the design rationale (why `vault-custom.js`, why the minimal `vault.html` patch, why we don't fork aggressively).
## Quick Start
```bash
# Clone
git clone https://github.com/LukasHuber/hermes-wiki-viewer.git
cd hermes-wiki-viewer
# Optional: PyYAML for frontmatter
pip install -r requirements.txt
# Point to your vault
python3 server.py --vault /path/to/your/vault --host 127.0.0.1 --port 8765
# Open http://localhost:8765
```
For Tailscale access (Lukas' typical setup), pair with `tailscale serve`:
```bash
tailscale serve --bg --https=443 http://127.0.0.1:8765
# Access via https://<hostname>.<tailnet>.ts.net/
```
## Customization
`vault-custom.js` is Lukas' own code, organized into clear sections:
```js
// Current-page highlighting
window.HermesCustom = window.HermesCustom || {};
HermesCustom.highlightCurrentNode = function() { ... };
// Graph click-to-navigate
HermesCustom.setupGraphClickHandler = function() { ... };
// Responsive layout
HermesCustom.responsiveLayout = function() { ... };
```
To add a new customization, add a method to `HermesCustom` and call it from `HermesCustom.init()` at the bottom of the file.
## Updating from upstream
```bash
git remote add upstream https://github.com/DanielCheer/obsidian-web-viewer.git
git fetch upstream
git merge upstream/master
# Conflicts should only occur in vault.html — the one-line patch
```
If `vault.html` has been heavily modified upstream, manually re-apply the single patch:
```html
<!-- Add before </body>: -->
<script src="vault-custom.js"></script>
```
## License
MIT — same as upstream. See [LICENSE](LICENSE).
## Author
Lukas Huber — see [Personal-Profile](https://github.com/LukasHuber) for context.
This fork exists to make the agent-wiki viewing experience fit Lukas' specific needs (3D graph focus, responsive layout for mobile reading, current-page highlighting for fast cross-page navigation).
---
Upstream: [DanielCheer/obsidian-web-viewer](https://github.com/DanielCheer/obsidian-web-viewer) © 2026 Daniel Cheer
Hermes Wiki Viewer fork © 2026 Lukas Huber
+95
View File
@@ -0,0 +1,95 @@
# Architecture
## Why this fork exists
[DanielCheer/obsidian-web-viewer](https://github.com/DanielCheer/obsidian-web-viewer) is a great single-file Obsidian-vault viewer (server.py + vault.html, ~10 KB + 14 KB, Python stdlib only). But for Lukas' Hermes Wiki workflow — 245 Markdown files across 18 top-level folders, frequent cross-referencing via WikiLinks, mobile reading — three specific UX gaps needed fixing:
1. **3D Graph shows all nodes uniformly** — no indication of which node the user is currently reading or which nodes are connected to it
2. **Layout breaks on small screens** — the 3-panel grid (`280px 1fr 260px`) is too narrow on 13" laptops and unusable on phones
3. **Click-to-navigate from graph** — Three.js canvas accepts clicks but doesn't trigger navigation
## Design principles
### 1. Additive layer, not fork-and-modify
We modify **exactly one line** in `vault.html` (a `<script src="vault-custom.js"></script>` tag before `</body>`). Everything else — including the customizations themselves — lives in `vault-custom.js`.
Why:
- Upstream `git pull` conflicts are trivial to resolve (the one line is recognizable)
- Customizations are clearly separated from upstream code
- Reviewing customizations is reading one file, not diffing the whole repo
- Easier to upstream-merge back if Lukas' features prove generally useful
### 2. HermesCustom namespace
All custom code lives under `window.HermesCustom`. Methods are namespaced and self-documenting:
```js
window.HermesCustom = window.HermesCustom || {};
HermesCustom.highlightCurrentNode = function() { ... };
HermesCustom.setupGraphClickHandler = function() { ... };
HermesCustom.responsiveLayout = function() { ... };
```
This way the namespace is visible in DevTools and a future maintainer can see at a glance what's custom vs. upstream.
### 3. Server.py stays mostly upstream
We add **one** enhancement to `server.py`: a `?file=<path>` query parameter that returns the file directly (used by `HermesCustom.highlightCurrentNode` to know which file is open). This is a 5-line change that doesn't conflict with any upstream logic.
If upstream adds a similar feature, we drop our addition.
## File map
```
hermes-wiki-viewer/
├── server.py # obv-fork + 5 lines (file-query-param)
├── vault.html # obv-fork + 1 line (script tag for custom.js)
├── vault-custom.js # ALL Lukas-specific code
├── requirements.txt # PyYAML only (same as upstream)
├── README.md # this repo's entry point
├── LICENSE # MIT (inherited)
├── CHANGELOG.md # release notes for Lukas-additions
├── docs/
│ ├── ARCHITECTURE.md # you are here
│ └── CUSTOMIZATIONS.md # spec for each Lukas-feature
└── .gitignore # standard
```
## Why we keep `vault-custom.js` separate from `vault.html`
Embedding customizations into `vault.html` would mean:
- Every upstream update requires re-applying the entire diff
- Reviewing customizations requires diffing two HTML files
- Conflicts when upstream renames a function we override
A separate file means:
- The customizations are a stable, reviewable unit
- Upstream updates touch `vault.html` only, customizations are unchanged
- The one-line patch in `vault.html` is a clear "anchor point" that any reviewer can understand
## Why Python stdlib only (no Flask/FastAPI)
Upstream uses `http.server` from stdlib. We keep this. Adding a framework would:
- Inflate dependencies
- Require version-pinning for reproducibility
- Make the tool harder to deploy (anywhere with Python 3.8+ works)
The 5-line addition to `server.py` is plain stdlib `BaseHTTPRequestHandler` style.
## Tradeoffs we accepted
- **No build step.** Customizations are vanilla JS. No bundler, no TypeScript. Means no static type-checking, but means anyone can read and modify the code without toolchain setup.
- **No tests in CI.** We rely on manual testing for now. CI tests would add complexity that doesn't match the project's "simple tool" character. (Future: a small Playwright test for the graph highlighting.)
- **No auto-update from upstream.** When obv updates, Lukas has to `git pull upstream && git merge`. The one-line `vault.html` patch needs re-application if upstream modified it. This is acceptable for a personal side-project.
## Roadmap
See CHANGELOG.md for planned additions. Current priorities:
1. ✅ Current-page highlighting in 3D Graph
2. ✅ Click-to-navigate on graph
3. ✅ Responsive layout (1024px and 768px breakpoints)
4. ⏳ Backlinks panel (per-page incoming WikiLinks)
5. ⏳ Keyboard shortcuts (j/k for next/prev file, [/] for nav, g/G for graph focus)
6. ⏳ Light theme variant (currently Catppuccin-dark only)
+120
View File
@@ -0,0 +1,120 @@
# Customizations — Feature Spec
Each section describes one Lukas-feature: what it does, how it works, where the code lives, and known limitations.
## 1. Current-page highlighting in 3D Graph
**What:** When you open a file, the corresponding node in the 3D Graph becomes brighter and larger. Nodes that the open file links to (via `[[wikilinks]]`) also brighten. All other nodes dim.
**How:**
1. `HermesCustom.getCurrentFile()` reads the file path from `?file=` URL param (preferred) or tracks via the `loadFile()` hook
2. `HermesCustom.getConnectedFiles(currentFile)` looks up outgoing edges in the global graph data
3. `HermesCustom.highlightCurrentNode()` iterates `window.graphNodeObjects` (obv's Three.js node meshes) and adjusts `material.color`, `emissive`, `scale`, `opacity`
**Code:** `vault-custom.js`, methods under namespace `HermesCustom.*`.
**Materials used (Catppuccin palette, matches theme):**
- Current: `#FFD700` (gold) + emissive glow + 1.5x scale
- Connected: `#89b4fa` (light blue) + faint emissive + 1.1x scale
- Other: `#313244` (muted) + 20% opacity (transparent)
**Known limitation:**
- Uses `window.graphNodeObjects` global from obv — if obv renames this, we need to update. Mitigation: detection in `highlightCurrentNode` waits up to 500ms for obv's graph to be ready.
- Highlight doesn't preserve selection across page reload — fresh fetch on every load.
## 2. Click-to-navigate on 3D Graph
**What:** Clicking a node in the 3D Graph navigates to that file's page.
**How:**
1. `HermesCustom.setupGraphClickHandler()` sets up a Three.js Raycaster on the canvas DOM element
2. On click, computes normalized mouse coords, raycasts into scene
3. First intersection's `userData.nodeId` is the file path
4. Calls `window.loadFile(nodeId)` (obv's existing loader)
5. Updates URL with `window.history.pushState({}, '', '?file=<path>')`
**Code:** `vault-custom.js`, ~25 lines.
**Known limitation:**
- Three.js raycasting requires nodes to have `userData.nodeId` set — verified in obv's `addNodeToScene` function (line ~310 in obv's graph render). If obv removes this, our handler silently does nothing.
- No keyboard navigation in graph (yet). Roadmap item.
## 3. Responsive Layout
**What:** The 3-panel desktop layout collapses gracefully on smaller screens.
**Breakpoints:**
- **Desktop (≥1024px):** Full 3-panel layout — Tree 240px | Content 1fr | Graph 240px
- **Tablet (768px1023px):** Tree 240px | Content 1fr | Graph 200px (bottom panel)
- **Mobile (<768px):** Tree as hamburger menu (off-canvas, slides in from left) | Content full-width | Graph hidden
**How:**
- `HermesCustom.injectResponsiveCSS()` adds `<style>` tag with media queries
- `HermesCustom.responsiveLayout()` adds `.mobile-mode` / `.tablet-mode` classes to `.vault-app` based on `window.innerWidth`
- Resize handler is throttled (150ms) to avoid jank
- Hamburger toggle button is appended to header in mobile mode
**Code:** `vault-custom.js`, ~80 lines (including CSS string).
**Known limitation:**
- The injected CSS uses `!important` to override obv's `grid-template-columns`. If obv restructures the layout, we may need to adjust selectors.
- Hamburger menu doesn't auto-close on file selection — minor UX nit. Roadmap.
## 4. URL-based navigation (?file=)
**What:** The URL contains the currently-open file as a `?file=<path>` query param. This makes pages deep-linkable and back-button friendly.
**How:**
- `HermesCustom.getCurrentFile()` reads `?file=` from `URLSearchParams`
- `vault-custom.js` updates URL via `history.pushState()` on file load
- Server.py patch accepts `?file=` in API calls
**Server-side patch (server.py):**
```python
# Lukas-add: also support ?file=<path> query for clean URL navigation
if "?" in rel:
rel, _, query = rel.partition("?")
from urllib.parse import parse_qs
file_param = parse_qs(query).get("file", [None])[0]
if file_param:
rel = file_param
```
**Known limitation:**
- Server-side patch assumes `?file=` is the only query parameter obv might add. If obv adds another (e.g. `?theme=`), the simple `partition("?")` still works but is naive. Acceptable for current state.
## 5. Tracking current file via loadFile hook
**What:** `vault-custom.js` patches obv's `window.loadFile` to remember the last loaded path. Used as a fallback when the URL doesn't have `?file=` (e.g., when obv opens a file from search without updating URL).
**How:**
```js
const originalLoadFile = window.loadFile;
window.loadFile = function(path) {
HermesCustom._lastLoadedFile = path;
const result = originalLoadFile.apply(this, arguments);
setTimeout(HermesCustom.highlightCurrentNode, 300);
return result;
};
```
**Known limitation:**
- The 300ms delay assumes the render finishes within that time. Slow devices may need a longer delay. Could be improved by hooking into a render-completion signal if obv provides one.
---
## Testing
Manual testing only for now. Test cases:
| Scenario | Expected |
|----------|----------|
| Open `concepts/agent-reference-model.md` | Graph: that node gold + glow, connected nodes light blue, others dim |
| Open file via search (not via tree click) | Same highlighting (URL gets `?file=`) |
| Click graph node | File loads, URL updates |
| Resize browser to <1024px | Graph moves to bottom panel |
| Resize browser to <768px | Tree becomes hamburger, graph hidden |
| Hard reload on file URL | File loads directly (server.py parses `?file=`) |
| Upstream obv update (theoretical) | Only 1 line of conflict in vault.html, easy merge |
Planned: Playwright-based automated test for the 5 visual features.
+4
View File
@@ -0,0 +1,4 @@
# Obsidian Web Viewer — no required external dependencies
# Python 3.8+ standard library only
# Optional: PyYAML for frontmatter parsing
PyYAML>=6.0
+300
View File
@@ -0,0 +1,300 @@
"""
Obsidian Web Viewer — Browse Your Vault in the Browser
========================================================
Web-based Obsidian vault browser with 3D graph, file tree, markdown
rendering, wikilink navigation, and full-text search.
Usage:
python server.py --vault /path/to/your/vault
python server.py --vault ~/Documents/MyVault --port 8080
"""
import sys
import os
import json
import re
import argparse
import time
import threading
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from datetime import datetime
from urllib.parse import unquote, quote
if sys.platform == 'win32':
try:
sys.stdout.reconfigure(encoding='utf-8')
except Exception:
pass
ROOT = Path(__file__).parent
VAULT_DIR = None # Set via --vault CLI arg
# YAML parsing (optional)
_YAML_AVAILABLE = False
try:
import yaml
_YAML_AVAILABLE = True
except ImportError:
pass
MIME = {
".html": "text/html; charset=utf-8",
".css": "text/css",
".js": "application/javascript",
".json": "application/json",
".png": "image/png",
".jpg": "image/jpeg",
".svg": "image/svg+xml",
}
# Cache for vault tree and graph
_cache = {"tree": None, "graph": None, "ts": 0}
_CACHE_TTL = 300
def _build_tree(vault_dir: Path) -> dict:
"""Build a JSON file tree of the vault directory."""
now = time.time()
if _cache["tree"] and (now - _cache["ts"]) < _CACHE_TTL:
return _cache["tree"]
def _walk(d: Path) -> dict:
node = {"type": "folder", "name": d.name, "children": []}
try:
entries = sorted(d.iterdir(), key=lambda e: (not e.is_dir(), e.name.lower()))
except PermissionError:
return node
for entry in entries:
if entry.name.startswith('.') or entry.name.startswith('_'):
continue
if entry.is_dir():
child = _walk(entry)
if child["children"]:
node["children"].append(child)
elif entry.suffix.lower() == '.md':
node["children"].append({"type": "file", "name": entry.name})
return node
if not vault_dir.exists():
return {"type": "folder", "name": "", "children": []}
tree = _walk(vault_dir)
tree["name"] = ""
_cache["tree"] = tree
_cache["ts"] = time.time()
return tree
def _parse_frontmatter(raw: str) -> tuple:
"""Parse YAML frontmatter from markdown. Returns (dict, body_str)."""
if not raw.startswith('---'):
return {}, raw
end = raw.find('---', 3)
if end == -1:
return {}, raw
fm_text = raw[3:end].strip()
body = raw[end + 3:].strip()
fm = {}
if _YAML_AVAILABLE:
try:
fm = yaml.safe_load(fm_text) or {}
if not isinstance(fm, dict):
fm = {"value": fm}
except Exception:
fm = {}
else:
for line in fm_text.splitlines():
if ':' in line:
k, _, v = line.partition(':')
v = v.strip().strip('"').strip("'")
if v.startswith('[') and v.endswith(']'):
v = [x.strip().strip('"').strip("'") for x in v[1:-1].split(',') if x.strip()]
fm[k.strip()] = v
return fm, body
def _search(vault_dir: Path, query: str, max_results: int = 30) -> list:
"""Search vault files by name and content."""
results = []
query_lower = query.lower()
for md_file in vault_dir.rglob("*.md"):
if md_file.name.startswith('.') or md_file.name.startswith('_'):
continue
try:
rel = md_file.relative_to(vault_dir).as_posix()
except ValueError:
continue
name = md_file.stem
name_match = query_lower in name.lower()
snippet = ""
content_match = False
if not name_match:
try:
text = md_file.read_text(encoding="utf-8", errors="ignore")[:5000]
idx = text.lower().find(query_lower)
if idx >= 0:
content_match = True
start = max(0, idx - 40)
end = min(len(text), idx + len(query) + 60)
snippet = ("..." if start > 0 else "") + text[start:end].replace("\n", " ") + ("..." if end < len(text) else "")
except Exception:
continue
if name_match or content_match:
results.append({
"path": rel, "name": name,
"folder": str(md_file.parent.relative_to(vault_dir)) if md_file.parent != vault_dir else "",
"snippet": snippet, "name_match": name_match,
})
if len(results) >= max_results:
break
results.sort(key=lambda r: (not r["name_match"], r["name"].lower()))
return results
def _build_graph(vault_dir: Path) -> dict:
"""Build a graph of wikilink connections between vault notes."""
if _cache["graph"] and (time.time() - _cache["ts"]) < _CACHE_TTL:
return _cache["graph"]
wikilink_re = re.compile(r'\[\[([^\]|]+?)(?:\|[^\]]+?)?\]\]')
nodes = []
edges = []
name_to_path = {}
all_paths = []
for md_file in vault_dir.rglob("*.md"):
if md_file.name.startswith('.') or md_file.name.startswith('_'):
continue
try:
rel = md_file.relative_to(vault_dir).as_posix()
except ValueError:
continue
all_paths.append((rel, md_file))
name_to_path[md_file.stem.lower()] = rel
for rel, md_file in all_paths:
try:
text = md_file.read_text(encoding="utf-8", errors="ignore")
except Exception:
text = ""
# Classify by folder for coloring
rp = rel.lower()
node_type = "other"
for folder in ["wiki", "reference", "daily", "journal", "notes", "projects"]:
if rp.startswith(folder + "/"):
node_type = folder
break
nodes.append({"id": rel, "label": md_file.stem, "type": node_type})
links = wikilink_re.findall(text)
seen = set()
for target in links:
resolved = name_to_path.get(target.strip().lower())
if resolved and resolved != rel and resolved not in seen:
edges.append({"source": rel, "target": resolved})
seen.add(resolved)
result = {"nodes": nodes, "edges": edges}
_cache["graph"] = result
return result
class VaultHandler(BaseHTTPRequestHandler):
def log_message(self, *a): pass
def do_GET(self):
path = self.path.split("?")[0].rstrip("/")
query_string = self.path.split("?")[1] if "?" in self.path else ""
if path == "" or path == "/" or path == "/vault":
self._serve_file(ROOT / "vault.html")
elif path == "/api/vault/tree":
self._json(_build_tree(VAULT_DIR))
elif path == "/api/vault/graph":
self._json(_build_graph(VAULT_DIR))
elif path == "/api/vault/search":
import urllib.parse
params = urllib.parse.parse_qs(query_string)
q = params.get("q", [""])[0]
self._json({"results": _search(VAULT_DIR, q) if q else []})
elif path.startswith("/api/vault/file/"):
rel = unquote(path[len("/api/vault/file/"):])
# Lukas-add: also support ?file=<path> query for clean URL navigation
if "?" in rel:
rel, _, query = rel.partition("?")
from urllib.parse import parse_qs
file_param = parse_qs(query).get("file", [None])[0]
if file_param:
rel = file_param
file_path = VAULT_DIR / rel
if not file_path.exists() or not str(file_path).startswith(str(VAULT_DIR)):
self._json({"error": "Not found"}, 404)
return
try:
raw = file_path.read_text(encoding="utf-8")
fm, body = _parse_frontmatter(raw)
self._json({"path": rel, "name": file_path.stem, "frontmatter": fm, "body": body})
except Exception as e:
self._json({"error": str(e)}, 500)
elif path == "/config.json":
self._serve_file(ROOT / "config.json")
else:
f = ROOT / path.lstrip("/")
if f.exists() and f.is_file():
self._serve_file(f)
else:
self.send_error(404)
def _serve_file(self, path):
if not path.exists():
self.send_error(404); return
data = path.read_bytes()
mime = MIME.get(path.suffix.lower(), "application/octet-stream")
self.send_response(200)
self.send_header("Content-Type", mime)
self.send_header("Content-Length", str(len(data)))
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Cache-Control", "no-cache")
self.end_headers()
self.wfile.write(data)
def _json(self, obj, code=200):
data = json.dumps(obj, indent=2, default=str).encode()
self.send_response(code)
self.send_header("Content-Type", "application/json")
self.send_header("Content-Length", str(len(data)))
self.send_header("Access-Control-Allow-Origin", "*")
self.send_header("Cache-Control", "no-cache")
self.end_headers()
self.wfile.write(data)
def main():
parser = argparse.ArgumentParser(description="Obsidian Web Viewer")
parser.add_argument("--vault", required=True, help="Path to your Obsidian vault folder")
parser.add_argument("--port", type=int, default=8765)
parser.add_argument("--host", default="0.0.0.0")
args = parser.parse_args()
global VAULT_DIR
VAULT_DIR = Path(args.vault).resolve()
if not VAULT_DIR.exists():
print(f"ERROR: Vault path does not exist: {VAULT_DIR}")
sys.exit(1)
md_count = sum(1 for _ in VAULT_DIR.rglob("*.md"))
print(f"Obsidian Web Viewer")
print(f" Vault: {VAULT_DIR}")
print(f" Notes: {md_count}")
print(f" URL: http://{args.host}:{args.port}")
server = ThreadingHTTPServer((args.host, args.port), VaultHandler)
try:
server.serve_forever()
except KeyboardInterrupt:
print("\nShutting down.")
if __name__ == "__main__":
main()
+302
View File
@@ -0,0 +1,302 @@
/* HermesCustom — Lukas' additions to obsidian-web-viewer
*
* Loaded by vault.html (one-line patch) AFTER the main script. Initializes
* automatically via HermesCustom.init() at the bottom of this file.
*
* Features:
* 1. Current-page highlighting in 3D Graph
* 2. Click-to-navigate on 3D Graph nodes (Three.js raycasting)
* 3. Responsive layout (graph collapses below 1024px, tree below 768px)
*
* Design notes: see docs/ARCHITECTURE.md
*/
(function() {
'use strict';
window.HermesCustom = window.HermesCustom || {};
const HermesCustom = window.HermesCustom;
// ============================================================
// 1. Current-page highlighting
// ============================================================
/**
* Determine which file is currently being viewed.
* Uses URL ?file=<path> parameter, or falls back to extracting from the
* page content (the last file fetched via loadFile).
*/
HermesCustom.getCurrentFile = function() {
// Method 1: ?file= query param (preferred)
const params = new URLSearchParams(window.location.search);
const fileParam = params.get('file');
if (fileParam) return fileParam;
// Method 2: Parse from URL hash if obv uses hash-based routing
if (window.location.hash) {
const hashMatch = window.location.hash.match(/file=([^&]+)/);
if (hashMatch) return decodeURIComponent(hashMatch[1]);
}
// Method 3: Track via the last loadFile() call (set up in init)
return HermesCustom._lastLoadedFile || null;
};
/**
* Find all WikiLink targets in the currently loaded file's content.
* We hook into the renderMarkdown flow by tracking what was last loaded.
*/
HermesCustom.getConnectedFiles = function(currentFile) {
if (!currentFile) return [];
// Use the global graph data loaded by obv's fetch('/api/vault/graph')
const graphData = HermesCustom._graphData;
if (!graphData) return [];
const connections = graphData.edges
.filter(e => e.source === currentFile)
.map(e => e.target);
// Also outgoing from current file (WikiLinks from current page)
// And bidirectional awareness
return connections;
};
/**
* Highlight the current node + connected nodes in the 3D Graph.
* Uses obv's global graph variables (graphScene, graphNodeObjects).
*/
HermesCustom.highlightCurrentNode = function() {
const currentFile = HermesCustom.getCurrentFile();
if (!currentFile) return;
// Hook into obv's globals (set after graph render)
if (typeof window.graphScene === 'undefined' || !window.graphNodeObjects) {
// Graph not yet built — try again shortly
setTimeout(HermesCustom.highlightCurrentNode, 500);
return;
}
const connected = new Set(HermesCustom.getConnectedFiles(currentFile));
connected.add(currentFile);
// Iterate node meshes and adjust material
window.graphNodeObjects.forEach((mesh, nodeId) => {
const isCurrent = (nodeId === currentFile);
const isConnected = connected.has(nodeId) && !isCurrent;
if (isCurrent) {
// Bright + slightly larger + emissive glow
mesh.material.color.setHex(0xFFD700); // gold
mesh.material.emissive.setHex(0xFFD700);
mesh.material.emissiveIntensity = 0.6;
mesh.scale.set(1.5, 1.5, 1.5);
} else if (isConnected) {
// Brighter than default, smaller than current
mesh.material.color.setHex(0x89b4fa); // light blue
mesh.material.emissive.setHex(0x89b4fa);
mesh.material.emissiveIntensity = 0.3;
mesh.scale.set(1.1, 1.1, 1.1);
} else {
// Dim everything else
mesh.material.color.setHex(0x313244); // muted
mesh.material.emissiveIntensity = 0;
mesh.material.opacity = 0.2;
mesh.material.transparent = true;
}
});
};
// ============================================================
// 2. Click-to-navigate on 3D Graph
// ============================================================
HermesCustom.setupGraphClickHandler = function() {
if (!window.graphRenderer || !window.graphScene || !window.graphCamera) {
setTimeout(HermesCustom.setupGraphClickHandler, 500);
return;
}
const raycaster = new THREE.Raycaster();
const mouse = new THREE.Vector2();
window.graphRenderer.domElement.addEventListener('click', (event) => {
const rect = window.graphRenderer.domElement.getBoundingClientRect();
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
raycaster.setFromCamera(mouse, window.graphCamera);
const intersects = raycaster.intersectObjects(
Object.values(window.graphNodeObjects || {})
);
if (intersects.length > 0) {
const nodeId = intersects[0].object.userData.nodeId;
if (nodeId && typeof window.loadFile === 'function') {
window.loadFile(nodeId);
window.history.pushState({}, '', `?file=${encodeURIComponent(nodeId)}`);
}
}
});
};
// ============================================================
// 3. Responsive layout
// ============================================================
HermesCustom.responsiveLayout = function() {
const applyLayout = () => {
const app = document.querySelector('.vault-app');
if (!app) return;
const width = window.innerWidth;
if (width < 768) {
// Mobile: tree collapses to hamburger, graph hidden by default
app.classList.add('mobile-mode');
app.classList.remove('graph-visible');
} else if (width < 1024) {
// Tablet: tree visible, graph collapses to bottom panel
app.classList.add('tablet-mode');
app.classList.remove('mobile-mode');
} else {
// Desktop: full 3-panel layout
app.classList.remove('mobile-mode', 'tablet-mode');
}
};
// Throttled resize handler
let resizeTimer;
window.addEventListener('resize', () => {
clearTimeout(resizeTimer);
resizeTimer = setTimeout(applyLayout, 150);
});
applyLayout(); // Initial
};
/**
* Inject responsive CSS into <head> (since vault.html doesn't have these)
* Must run early so it doesn't conflict with obv's layout.
*/
HermesCustom.injectResponsiveCSS = function() {
if (document.getElementById('hermes-custom-css')) return;
const style = document.createElement('style');
style.id = 'hermes-custom-css';
style.textContent = `
/* === Tablet (768px - 1023px): graph → bottom panel === */
@media (max-width: 1023px) {
.vault-app.tablet-mode {
grid-template-columns: 240px 1fr !important;
grid-template-rows: 48px 1fr 200px !important;
}
.vault-app.tablet-mode .vault-graph {
grid-column: 1 / -1 !important;
grid-row: 3 !important;
border-left: none !important;
border-top: 1px solid var(--border);
}
.vault-app.tablet-mode .vault-content {
grid-column: 2 !important;
}
}
/* === Mobile (<768px): tree → hamburger menu === */
@media (max-width: 767px) {
.vault-app.mobile-mode {
grid-template-columns: 1fr !important;
grid-template-rows: 48px 1fr !important;
}
.vault-app.mobile-mode .vault-sidebar {
position: fixed;
top: 48px;
left: 0;
bottom: 0;
width: 280px;
transform: translateX(-100%);
transition: transform 0.2s ease;
z-index: 100;
}
.vault-app.mobile-mode .vault-sidebar.open {
transform: translateX(0);
}
.vault-app.mobile-mode .vault-graph {
display: none;
}
}
/* === Hamburger toggle button (mobile only) === */
.hermes-mobile-toggle {
display: none;
background: var(--bg-secondary);
border: 1px solid var(--border);
color: var(--text-secondary);
padding: 4px 10px;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
}
@media (max-width: 767px) {
.hermes-mobile-toggle { display: inline-block; }
}
`;
document.head.appendChild(style);
// Add hamburger button to header
const header = document.querySelector('.vault-header');
if (header && !document.querySelector('.hermes-mobile-toggle')) {
const btn = document.createElement('button');
btn.className = 'hermes-mobile-toggle';
btn.textContent = '☰ Tree';
btn.onclick = () => {
const sidebar = document.querySelector('.vault-sidebar');
if (sidebar) sidebar.classList.toggle('open');
};
header.insertBefore(btn, header.firstChild);
}
};
// ============================================================
// Init: wire everything together
// ============================================================
HermesCustom.init = function() {
console.log('[HermesCustom] init');
HermesCustom.injectResponsiveCSS();
HermesCustom.responsiveLayout();
// Hook into obv's loadFile to track current file + re-highlight
if (typeof window.loadFile === 'function') {
const originalLoadFile = window.loadFile;
window.loadFile = function(path) {
HermesCustom._lastLoadedFile = path;
const result = originalLoadFile.apply(this, arguments);
// Re-highlight after content renders
setTimeout(HermesCustom.highlightCurrentNode, 300);
return result;
};
}
// Fetch graph data once for highlight logic
fetch('/api/vault/graph')
.then(r => r.json())
.then(data => {
HermesCustom._graphData = data;
console.log(`[HermesCustom] Loaded graph: ${data.nodes.length} nodes, ${data.edges.length} edges`);
HermesCustom.highlightCurrentNode();
})
.catch(err => console.warn('[HermesCustom] Graph fetch failed:', err));
// Click handler + responsive layout (need to wait for Three.js to init)
setTimeout(() => {
HermesCustom.setupGraphClickHandler();
}, 1500);
};
// Auto-init when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', HermesCustom.init);
} else {
HermesCustom.init();
}
})();
+354
View File
@@ -0,0 +1,354 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Vault Viewer</title>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<style>
:root {
--bg-primary: #0a0a14; --bg-secondary: #0e0e1a; --bg-surface: #1a1a2e;
--text-primary: #cdd6f4; --text-secondary: #a6adc8; --text-muted: #6c7086;
--accent: #FFD700; --link: #FFD700; --border: #313244;
--green: #a6e3a1; --red: #f38ba8; --blue: #89b4fa; --teal: #94e2d5;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: var(--bg-primary); color: var(--text-primary); font-family: -apple-system, 'Segoe UI', Inter, sans-serif; height: 100vh; overflow: hidden; }
.vault-app {
display: grid; grid-template-columns: 280px 1fr 260px;
grid-template-rows: 48px 1fr; height: 100vh;
}
.vault-app.graph-hidden { grid-template-columns: 280px 1fr; }
/* Header */
.vault-header {
grid-column: 1 / -1; background: var(--bg-secondary);
border-bottom: 1px solid var(--border); display: flex; align-items: center;
padding: 0 16px; gap: 12px;
}
.vault-header h1 { color: var(--accent); font-size: 14px; font-weight: 700; letter-spacing: 1px; }
.vault-header .search-box {
flex: 1; max-width: 400px; position: relative;
}
.vault-header input {
width: 100%; padding: 6px 12px 6px 32px; background: rgba(30,30,50,0.6);
border: 1px solid var(--border); border-radius: 6px; color: var(--text-primary);
font-size: 13px; outline: none;
}
.vault-header input:focus { border-color: var(--accent); }
.vault-header .toggle-btn {
background: none; border: 1px solid var(--border); color: var(--text-muted);
padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 12px;
}
.vault-header .toggle-btn:hover { border-color: var(--accent); color: var(--accent); }
/* Sidebar file tree */
.vault-sidebar {
background: var(--bg-secondary); border-right: 1px solid var(--border);
overflow-y: auto; padding: 8px 0; font-size: 13px;
}
.tree-folder { cursor: pointer; user-select: none; }
.tree-folder-header {
display: flex; align-items: center; padding: 4px 12px; gap: 6px;
color: var(--text-secondary); border-radius: 4px; margin: 1px 4px;
}
.tree-folder-header:hover { background: rgba(255,215,0,0.05); }
.tree-folder-header .arrow { color: var(--text-muted); font-size: 10px; width: 12px; }
.tree-children { padding-left: 12px; display: none; }
.tree-children.open { display: block; }
.tree-file {
display: flex; align-items: center; padding: 4px 12px; gap: 6px;
color: var(--text-primary); cursor: pointer; border-radius: 4px; margin: 1px 4px;
font-size: 13px;
}
.tree-file:hover { background: rgba(255,215,0,0.05); }
.tree-file.active { background: rgba(255,215,0,0.1); color: var(--accent); }
/* Content area */
.vault-content {
overflow-y: auto; padding: 32px 48px; max-width: 900px;
line-height: 1.7; font-size: 15px;
}
.vault-content h1 { color: var(--accent); font-size: 28px; margin: 24px 0 12px; }
.vault-content h2 { color: var(--accent); font-size: 22px; margin: 20px 0 10px; border-bottom: 1px solid var(--border); padding-bottom: 4px; }
.vault-content h3 { color: var(--text-primary); font-size: 18px; margin: 16px 0 8px; }
.vault-content p { margin: 8px 0; }
.vault-content a { color: var(--link); text-decoration: none; }
.vault-content a:hover { text-decoration: underline; }
.vault-content code { background: var(--bg-surface); padding: 2px 6px; border-radius: 4px; font-size: 13px; color: var(--teal); }
.vault-content pre { background: var(--bg-surface); padding: 16px; border-radius: 8px; overflow-x: auto; margin: 12px 0; }
.vault-content pre code { padding: 0; background: none; }
.vault-content blockquote { border-left: 3px solid var(--accent); padding-left: 16px; color: var(--text-secondary); margin: 12px 0; }
.vault-content ul, .vault-content ol { padding-left: 24px; }
.vault-content li { margin: 4px 0; }
.vault-content table { border-collapse: collapse; width: 100%; margin: 12px 0; }
.vault-content th, .vault-content td { border: 1px solid var(--border); padding: 8px 12px; text-align: left; }
.vault-content th { background: var(--bg-surface); color: var(--accent); font-size: 13px; }
.vault-content img { max-width: 100%; border-radius: 8px; }
.frontmatter { background: var(--bg-surface); border: 1px solid var(--border); border-radius: 8px; padding: 12px 16px; margin-bottom: 16px; font-size: 12px; }
.frontmatter .fm-key { color: var(--text-muted); }
.frontmatter .fm-val { color: var(--text-primary); margin-left: 8px; }
/* Graph panel */
.vault-graph {
background: var(--bg-secondary); border-left: 1px solid var(--border);
position: relative; overflow: hidden;
}
.vault-graph canvas { width: 100% !important; height: 100% !important; }
/* Search results overlay */
#search-results {
position: absolute; top: 48px; left: 50%; transform: translateX(-50%);
width: 420px; max-height: 400px; overflow-y: auto;
background: rgba(14,14,26,0.95); border: 1px solid var(--border);
border-radius: 8px; z-index: 100; display: none;
backdrop-filter: blur(12px);
}
.search-item {
padding: 8px 14px; cursor: pointer; border-bottom: 1px solid rgba(49,50,68,0.5);
}
.search-item:hover { background: rgba(255,215,0,0.05); }
.search-item .name { color: var(--accent); font-weight: 600; }
.search-item .folder { color: var(--text-muted); font-size: 11px; margin-left: 8px; }
.search-item .snippet { color: var(--text-secondary); font-size: 12px; margin-top: 2px; }
/* Welcome screen */
.welcome {
display: flex; flex-direction: column; align-items: center; justify-content: center;
height: 100%; color: var(--text-muted); text-align: center;
}
.welcome h2 { color: var(--accent); margin-bottom: 8px; }
</style>
</head>
<body>
<div class="vault-app" id="app">
<div class="vault-header">
<h1>Vault</h1>
<div class="search-box">
<input type="text" id="search-input" placeholder="Search notes..." oninput="onSearch(this.value)" onfocus="showSearch()" onblur="setTimeout(hideSearch, 200)">
</div>
<button class="toggle-btn" onclick="toggleGraph()">Graph</button>
</div>
<div class="vault-sidebar" id="sidebar"></div>
<div class="vault-content" id="content">
<div class="welcome">
<h2>Welcome to Vault Viewer</h2>
<p>Select a note from the file tree or use search.</p>
</div>
</div>
<div class="vault-graph" id="graph-panel">
<canvas id="graph-canvas"></canvas>
</div>
</div>
<div id="search-results"></div>
<script>
let currentFile = null;
let graphVisible = true;
let graphData = null;
// Load file tree
async function loadTree() {
const resp = await fetch('/api/vault/tree');
const tree = await resp.json();
document.getElementById('sidebar').innerHTML = renderTree(tree, '');
}
function renderTree(node, path) {
if (node.type === 'file') {
const fullPath = path ? path + '/' + node.name : node.name;
return `<div class="tree-file" onclick="loadFile('${fullPath.replace(/'/g, "\\'")}')" data-path="${fullPath}">${node.name.replace('.md', '')}</div>`;
}
const fullPath = path ? path + '/' + node.name : node.name;
let html = '';
if (node.name) {
html += `<div class="tree-folder">
<div class="tree-folder-header" onclick="this.nextElementSibling.classList.toggle('open');this.querySelector('.arrow').textContent=this.nextElementSibling.classList.contains('open')?'v':'>'">
<span class="arrow">></span> ${node.name}
</div>
<div class="tree-children">`;
}
(node.children || []).forEach(c => { html += renderTree(c, fullPath); });
if (node.name) html += '</div></div>';
return html;
}
// Load file content
async function loadFile(path) {
// Highlight active
document.querySelectorAll('.tree-file').forEach(el => el.classList.remove('active'));
const activeEl = document.querySelector(`.tree-file[data-path="${path}"]`);
if (activeEl) activeEl.classList.add('active');
const resp = await fetch('/api/vault/file/' + encodeURIComponent(path));
const data = await resp.json();
if (data.error) {
document.getElementById('content').innerHTML = `<p style="color:var(--red)">${data.error}</p>`;
return;
}
currentFile = path;
let html = '';
// Frontmatter
if (data.frontmatter && Object.keys(data.frontmatter).length > 0) {
html += '<div class="frontmatter">';
Object.entries(data.frontmatter).forEach(([k, v]) => {
const val = Array.isArray(v) ? v.join(', ') : String(v);
html += `<div><span class="fm-key">${k}:</span><span class="fm-val">${val}</span></div>`;
});
html += '</div>';
}
// Markdown body — convert wikilinks first
let body = data.body || '';
body = body.replace(/\[\[([^\]|]+?)(?:\|([^\]]+?))?\]\]/g, (match, target, alias) => {
const display = alias || target;
return `<a href="#" onclick="navigateWikilink('${target.replace(/'/g, "\\'")}');return false">${display}</a>`;
});
html += marked.parse(body);
document.getElementById('content').innerHTML = html;
document.getElementById('content').scrollTop = 0;
}
// Wikilink navigation
async function navigateWikilink(target) {
// Search for the target file
const resp = await fetch('/api/vault/search?q=' + encodeURIComponent(target));
const data = await resp.json();
if (data.results && data.results.length > 0) {
// Find exact name match first
const exact = data.results.find(r => r.name.toLowerCase() === target.toLowerCase());
loadFile((exact || data.results[0]).path);
}
}
// Search
let searchTimeout = null;
function onSearch(query) {
clearTimeout(searchTimeout);
if (!query.trim()) { hideSearch(); return; }
searchTimeout = setTimeout(async () => {
const resp = await fetch('/api/vault/search?q=' + encodeURIComponent(query));
const data = await resp.json();
const el = document.getElementById('search-results');
if (!data.results || data.results.length === 0) {
el.innerHTML = '<div class="search-item"><span style="color:var(--text-muted)">No results</span></div>';
} else {
el.innerHTML = data.results.map(r =>
`<div class="search-item" onmousedown="loadFile('${r.path.replace(/'/g, "\\'")}');hideSearch()">
<div><span class="name">${r.name}</span><span class="folder">${r.folder}</span></div>
${r.snippet ? '<div class="snippet">' + r.snippet + '</div>' : ''}
</div>`
).join('');
}
el.style.display = 'block';
}, 200);
}
function showSearch() { if (document.getElementById('search-input').value) onSearch(document.getElementById('search-input').value); }
function hideSearch() { document.getElementById('search-results').style.display = 'none'; }
// Graph toggle
function toggleGraph() {
graphVisible = !graphVisible;
document.getElementById('app').classList.toggle('graph-hidden', !graphVisible);
if (graphVisible && !graphData) loadGraph();
}
// 3D Graph
let graphScene, graphCamera, graphRenderer;
async function loadGraph() {
const resp = await fetch('/api/vault/graph');
graphData = await resp.json();
const container = document.getElementById('graph-panel');
const canvas = document.getElementById('graph-canvas');
const W = container.clientWidth;
const H = container.clientHeight;
graphScene = new THREE.Scene();
graphScene.background = new THREE.Color(0x0e0e1a);
graphCamera = new THREE.PerspectiveCamera(60, W / H, 1, 2000);
graphCamera.position.set(0, 0, 300);
graphRenderer = new THREE.WebGLRenderer({ canvas, antialias: true });
graphRenderer.setSize(W, H);
// Position nodes in a 3D force layout (simplified)
const nodePositions = {};
const nodes = graphData.nodes || [];
nodes.forEach((n, i) => {
const angle = (i / nodes.length) * Math.PI * 2;
const radius = 80 + Math.random() * 120;
nodePositions[n.id] = {
x: Math.cos(angle) * radius + (Math.random() - 0.5) * 40,
y: (Math.random() - 0.5) * 100,
z: Math.sin(angle) * radius + (Math.random() - 0.5) * 40,
};
});
// Node dots
const colors = { wiki: 0x89b4fa, daily: 0xf9e2af, journal: 0xa6e3a1, projects: 0xf5c2e7, other: 0x6c7086 };
nodes.forEach(n => {
const pos = nodePositions[n.id];
const color = colors[n.type] || colors.other;
const geo = new THREE.SphereGeometry(1.5, 8, 8);
const mat = new THREE.MeshBasicMaterial({ color });
const mesh = new THREE.Mesh(geo, mat);
mesh.position.set(pos.x, pos.y, pos.z);
graphScene.add(mesh);
});
// Edges
(graphData.edges || []).forEach(e => {
const s = nodePositions[e.source];
const t = nodePositions[e.target];
if (!s || !t) return;
const geo = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(s.x, s.y, s.z),
new THREE.Vector3(t.x, t.y, t.z),
]);
const mat = new THREE.LineBasicMaterial({ color: 0x313244, transparent: true, opacity: 0.3 });
graphScene.add(new THREE.Line(geo, mat));
});
// Orbit
let isDrag = false, lastX = 0, lastY = 0, rotX = 0, rotY = 0;
canvas.addEventListener('mousedown', e => { isDrag = true; lastX = e.clientX; lastY = e.clientY; });
canvas.addEventListener('mousemove', e => {
if (!isDrag) return;
rotY += (e.clientX - lastX) * 0.005;
rotX += (e.clientY - lastY) * 0.005;
lastX = e.clientX; lastY = e.clientY;
});
canvas.addEventListener('mouseup', () => isDrag = false);
canvas.addEventListener('wheel', e => {
graphCamera.position.z += e.deltaY * 0.5;
graphCamera.position.z = Math.max(50, Math.min(800, graphCamera.position.z));
});
function animateGraph() {
requestAnimationFrame(animateGraph);
graphScene.rotation.y = rotY;
graphScene.rotation.x = rotX;
graphRenderer.render(graphScene, graphCamera);
}
animateGraph();
}
// Init
loadTree();
loadGraph();
</script>
</body>
<script src="vault-custom.js"></script>
</html>