vestige/crates/vestige-mcp/src/graph.html
Sam Valladares c2d28f3433 feat: Vestige v2.0.0 "Cognitive Leap" — 3D dashboard, HyDE search, WebSocket events
The biggest release in Vestige history. Complete visual and cognitive overhaul.

Dashboard:
- SvelteKit 2 + Three.js 3D neural visualization at localhost:3927/dashboard
- 7 interactive pages: Graph, Memories, Timeline, Feed, Explore, Intentions, Stats
- WebSocket event bus with 16 event types, real-time 3D animations
- Bloom post-processing, GPU instanced rendering, force-directed layout
- Dream visualization mode, FSRS retention curves, command palette (Cmd+K)
- Keyboard shortcuts, responsive mobile layout, PWA installable
- Single binary deployment via include_dir! (22MB)

Engine:
- HyDE query expansion (intent classification + 3-5 semantic variants + centroid)
- fastembed 5.11 with optional Nomic v2 MoE + Qwen3 reranker + Metal GPU
- Emotional memory module (#29)
- Criterion benchmark suite

Backend:
- Axum WebSocket at /ws with heartbeat + event broadcast
- 7 new REST endpoints for cognitive operations
- Event emission from MCP tools via shared broadcast channel
- CORS for SvelteKit dev mode

Distribution:
- GitHub issue templates (bug report, feature request)
- CHANGELOG with comprehensive v2.0 release notes
- README updated with dashboard docs, architecture diagram, comparison table

734 tests passing, zero warnings, 22MB release binary.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-22 03:07:25 -06:00

1437 lines
54 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vestige Memory Graph</title>
<style>
*,*::before,*::after{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#0d1117;--bg-secondary:#161b22;--bg-tertiary:#21262d;
--text:#e6edf3;--text-secondary:#8b949e;
--border:#30363d;
--accent:#58a6ff;--accent-hover:#79c0ff;
--green:#3fb950;--yellow:#d29922;--red:#f85149;--purple:#bc8cff;--cyan:#39d2c0;--orange:#f0883e;
--radius:8px;
--font:-apple-system,BlinkMacSystemFont,"Segoe UI",Helvetica,Arial,sans-serif;
--mono:ui-monospace,SFMono-Regular,"SF Mono",Menlo,monospace;
--transition:0.15s ease;
}
html,body{height:100%;font-family:var(--font);background:var(--bg);color:var(--text);font-size:14px;line-height:1.5;overflow:hidden}
a{color:var(--accent);text-decoration:none}
a:hover{color:var(--accent-hover)}
button{font-family:var(--font);cursor:pointer;border:none;background:none;color:var(--text);font-size:inherit}
input,select{font-family:var(--font);font-size:inherit;color:var(--text);background:var(--bg-tertiary);border:1px solid var(--border);border-radius:var(--radius);padding:6px 10px;outline:none;transition:border-color var(--transition)}
input:focus,select:focus{border-color:var(--accent)}
::-webkit-scrollbar{width:6px}
::-webkit-scrollbar-track{background:var(--bg-secondary)}
::-webkit-scrollbar-thumb{background:var(--border);border-radius:3px}
/* Layout */
#app{display:grid;grid-template-rows:auto 1fr;grid-template-columns:260px 1fr 320px;height:100vh;overflow:hidden}
header{grid-column:1/-1;display:flex;align-items:center;justify-content:space-between;padding:10px 20px;background:var(--bg-secondary);border-bottom:1px solid var(--border)}
.header-left{display:flex;align-items:center;gap:14px}
.logo{font-size:20px;font-weight:700;letter-spacing:-0.5px;background:linear-gradient(135deg,var(--accent),var(--purple));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
.logo-sub{font-size:12px;color:var(--text-secondary);font-weight:400}
.header-right{display:flex;align-items:center;gap:8px}
.btn{padding:6px 14px;border-radius:var(--radius);font-size:12px;font-weight:500;transition:all var(--transition);border:1px solid var(--border);background:var(--bg-tertiary)}
.btn:hover{border-color:var(--text-secondary)}
.btn-accent{background:rgba(88,166,255,0.15);color:var(--accent);border-color:rgba(88,166,255,0.3)}
.btn-accent:hover{background:rgba(88,166,255,0.25)}
.btn-green{background:rgba(63,185,80,0.15);color:var(--green);border-color:rgba(63,185,80,0.3)}
.btn-green:hover{background:rgba(63,185,80,0.25)}
/* Controls Panel (left) */
.controls{grid-row:2;overflow-y:auto;background:var(--bg-secondary);border-right:1px solid var(--border);padding:16px}
.ctrl-section{margin-bottom:20px}
.ctrl-title{font-size:11px;text-transform:uppercase;letter-spacing:0.8px;color:var(--text-secondary);margin-bottom:8px;font-weight:600}
.ctrl-row{margin-bottom:10px}
.ctrl-row label{display:block;font-size:12px;color:var(--text-secondary);margin-bottom:4px}
.ctrl-row input[type=text],.ctrl-row select{width:100%}
.ctrl-row input[type=range]{width:100%;-webkit-appearance:none;appearance:none;background:var(--bg-tertiary);height:4px;border-radius:2px;border:none;padding:0;cursor:pointer}
.ctrl-row input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:14px;height:14px;border-radius:50%;background:var(--accent);cursor:pointer;border:none}
.range-value{font-size:11px;color:var(--accent);float:right;margin-top:-18px}
.legend{display:flex;flex-direction:column;gap:4px}
.legend-item{display:flex;align-items:center;gap:8px;font-size:11px;color:var(--text-secondary)}
.legend-dot{width:10px;height:10px;border-radius:50%;flex-shrink:0}
.legend-line{width:16px;height:3px;border-radius:1px;flex-shrink:0}
.tag-filter-list{display:flex;flex-wrap:wrap;gap:4px;margin-top:6px}
.tag-chip{font-size:10px;padding:2px 8px;border-radius:10px;background:var(--bg-tertiary);border:1px solid var(--border);color:var(--text-secondary);cursor:pointer;transition:all var(--transition)}
.tag-chip.active{background:rgba(88,166,255,0.15);border-color:var(--accent);color:var(--accent)}
.stats-mini{display:grid;grid-template-columns:1fr 1fr;gap:6px}
.stat-mini{background:var(--bg-tertiary);border-radius:var(--radius);padding:8px 10px;text-align:center}
.stat-mini-val{font-size:18px;font-weight:700;color:var(--accent)}
.stat-mini-label{font-size:10px;color:var(--text-secondary);text-transform:uppercase;letter-spacing:0.3px}
/* Graph Canvas (center) */
.graph-container{grid-row:2;position:relative;overflow:hidden;background:var(--bg)}
.graph-container svg{width:100%;height:100%;display:block}
.graph-loading{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;background:var(--bg);z-index:10;transition:opacity 0.3s}
.graph-loading.hidden{opacity:0;pointer-events:none}
.spinner{display:inline-block;width:20px;height:20px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin 0.6s linear infinite;margin-right:8px}
@keyframes spin{to{transform:rotate(360deg)}}
.graph-overlay{position:absolute;top:12px;left:12px;right:12px;display:flex;justify-content:space-between;pointer-events:none;z-index:5}
.graph-overlay>*{pointer-events:auto}
.search-graph{position:relative}
.search-graph input{width:240px;padding:8px 12px 8px 32px;background:rgba(22,27,34,0.9);backdrop-filter:blur(8px);font-size:13px}
.search-graph-icon{position:absolute;left:10px;top:50%;transform:translateY(-50%);color:var(--text-secondary)}
.zoom-controls{display:flex;gap:4px}
.zoom-controls button{width:32px;height:32px;border-radius:var(--radius);background:rgba(22,27,34,0.9);backdrop-filter:blur(8px);border:1px solid var(--border);display:flex;align-items:center;justify-content:center;font-size:16px;color:var(--text-secondary);transition:all var(--transition)}
.zoom-controls button:hover{color:var(--text);border-color:var(--text-secondary)}
/* Node tooltip */
.tooltip{position:absolute;background:rgba(22,27,34,0.95);border:1px solid var(--border);border-radius:var(--radius);padding:8px 12px;font-size:12px;pointer-events:none;z-index:20;max-width:280px;opacity:0;transition:opacity 0.15s;backdrop-filter:blur(8px);box-shadow:0 4px 12px rgba(0,0,0,0.4)}
.tooltip.visible{opacity:1}
.tooltip-label{font-weight:600;margin-bottom:2px;word-break:break-word}
.tooltip-meta{color:var(--text-secondary);font-size:11px}
/* Detail Panel (right) */
.detail-panel{grid-row:2;overflow-y:auto;background:var(--bg-secondary);border-left:1px solid var(--border);padding:16px}
.detail-empty{display:flex;flex-direction:column;align-items:center;justify-content:center;height:100%;color:var(--text-secondary);text-align:center;padding:20px}
.detail-empty svg{margin-bottom:12px;opacity:0.3}
.detail-header{margin-bottom:12px}
.detail-type{display:inline-block;font-size:10px;padding:2px 8px;border-radius:10px;font-weight:600;text-transform:uppercase;letter-spacing:0.3px;margin-bottom:6px}
.detail-id{font-size:10px;color:var(--text-secondary);font-family:var(--mono);word-break:break-all;margin-top:4px}
.detail-content{background:var(--bg-tertiary);border:1px solid var(--border);border-radius:var(--radius);padding:12px;margin-bottom:14px;white-space:pre-wrap;word-break:break-word;line-height:1.6;font-size:12px;max-height:200px;overflow-y:auto}
.detail-section{margin-bottom:14px}
.detail-section-title{font-size:10px;text-transform:uppercase;letter-spacing:0.8px;color:var(--text-secondary);margin-bottom:6px;font-weight:600}
.detail-grid{display:grid;grid-template-columns:1fr 1fr;gap:6px}
.detail-field{background:var(--bg-tertiary);border-radius:var(--radius);padding:8px 10px}
.detail-field-label{font-size:9px;text-transform:uppercase;letter-spacing:0.5px;color:var(--text-secondary);margin-bottom:1px}
.detail-field-value{font-size:12px;font-weight:500}
.detail-tags{display:flex;flex-wrap:wrap;gap:4px}
.detail-tag{font-size:10px;padding:2px 8px;border-radius:10px;background:var(--bg-tertiary);border:1px solid var(--border);color:var(--text-secondary)}
.detail-connections{margin-top:6px}
.conn-item{display:flex;align-items:center;gap:6px;padding:6px 8px;border-radius:var(--radius);background:var(--bg-tertiary);margin-bottom:4px;font-size:11px;cursor:pointer;transition:background var(--transition)}
.conn-item:hover{background:var(--bg)}
.conn-dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
.conn-label{flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.conn-strength{color:var(--text-secondary);font-size:10px;flex-shrink:0}
.detail-actions{display:flex;gap:6px;margin-top:14px}
/* Type colors */
.type-fact{background:rgba(88,166,255,0.15);color:#58a6ff}
.type-concept{background:rgba(188,140,255,0.15);color:#bc8cff}
.type-event{background:rgba(63,185,80,0.15);color:#3fb950}
.type-person{background:rgba(240,136,62,0.15);color:#f0883e}
.type-pattern{background:rgba(57,210,192,0.15);color:#39d2c0}
.type-decision{background:rgba(210,153,34,0.15);color:#d29922}
.type-note{background:rgba(139,148,158,0.15);color:#8b949e}
.type-place{background:rgba(248,81,73,0.15);color:#f85149}
/* Sidebar collapsed state */
#app.detail-closed{grid-template-columns:260px 1fr 0}
#app.detail-closed .detail-panel{display:none}
/* Toast */
.toast-container{position:fixed;bottom:20px;right:20px;z-index:1000}
.toast{padding:8px 16px;border-radius:var(--radius);font-size:12px;transform:translateY(60px);opacity:0;transition:all 0.3s;pointer-events:none;margin-top:6px}
.toast.visible{transform:translateY(0);opacity:1}
.toast.success{background:var(--green);color:#fff}
.toast.error{background:var(--red);color:#fff}
.toast.info{background:var(--accent);color:#fff}
/* Responsive: collapse controls on narrow screens */
@media(max-width:1000px){
#app{grid-template-columns:0 1fr 0}
.controls,.detail-panel{display:none}
}
/* Pulse animation for dream replay */
@keyframes pulse-glow{
0%,100%{filter:drop-shadow(0 0 2px currentColor)}
50%{filter:drop-shadow(0 0 12px currentColor)}
}
.dream-pulse{animation:pulse-glow 1.5s ease-in-out infinite}
</style>
</head>
<body>
<div id="app">
<header>
<div class="header-left">
<a href="/" style="text-decoration:none"><span class="logo">Vestige</span></a>
<span class="logo-sub">Memory Graph</span>
</div>
<div class="header-right">
<button class="btn btn-accent" id="js-btn-dream" title="Replay last dream connections">Dream Replay</button>
<button class="btn btn-green" id="js-btn-export" title="Export graph as PNG">Export PNG</button>
<a href="/" class="btn" title="Back to dashboard">Dashboard</a>
</div>
</header>
<!-- Controls Panel -->
<div class="controls">
<div class="ctrl-section">
<div class="ctrl-title">Graph Query</div>
<div class="ctrl-row">
<label>Search center node</label>
<input type="text" id="js-query" placeholder="e.g. 'rust patterns'" />
</div>
<div class="ctrl-row">
<label>Depth <span class="range-value" id="js-depth-val">2</span></label>
<input type="range" id="js-depth" min="1" max="3" value="2" />
</div>
<div class="ctrl-row">
<label>Max nodes <span class="range-value" id="js-max-val">50</span></label>
<input type="range" id="js-max-nodes" min="10" max="200" step="10" value="50" />
</div>
<div class="ctrl-row" style="margin-top:8px">
<button class="btn btn-accent" id="js-btn-load" style="width:100%">Load Graph</button>
</div>
</div>
<div class="ctrl-section">
<div class="ctrl-title">Filters</div>
<div class="ctrl-row">
<label>Node type</label>
<select id="js-filter-type">
<option value="">All types</option>
<option value="fact">Fact</option>
<option value="concept">Concept</option>
<option value="event">Event</option>
<option value="person">Person</option>
<option value="pattern">Pattern</option>
<option value="decision">Decision</option>
<option value="note">Note</option>
<option value="place">Place</option>
</select>
</div>
<div class="ctrl-row">
<label>Min retention <span class="range-value" id="js-ret-val">0%</span></label>
<input type="range" id="js-filter-ret" min="0" max="100" value="0" />
</div>
<div class="ctrl-row">
<label>Tags</label>
<div class="tag-filter-list" id="js-tag-filters"></div>
</div>
</div>
<div class="ctrl-section">
<div class="ctrl-title">Stats</div>
<div class="stats-mini">
<div class="stat-mini"><div class="stat-mini-val" id="js-node-count">0</div><div class="stat-mini-label">Nodes</div></div>
<div class="stat-mini"><div class="stat-mini-val" id="js-edge-count">0</div><div class="stat-mini-label">Edges</div></div>
</div>
</div>
<div class="ctrl-section">
<div class="ctrl-title">Node Types</div>
<div class="legend">
<div class="legend-item"><div class="legend-dot" style="background:#58a6ff"></div>Fact</div>
<div class="legend-item"><div class="legend-dot" style="background:#bc8cff"></div>Concept</div>
<div class="legend-item"><div class="legend-dot" style="background:#3fb950"></div>Event</div>
<div class="legend-item"><div class="legend-dot" style="background:#f0883e"></div>Person</div>
<div class="legend-item"><div class="legend-dot" style="background:#39d2c0"></div>Pattern</div>
<div class="legend-item"><div class="legend-dot" style="background:#d29922"></div>Decision</div>
<div class="legend-item"><div class="legend-dot" style="background:#8b949e"></div>Note</div>
</div>
</div>
<div class="ctrl-section">
<div class="ctrl-title">Edge Types</div>
<div class="legend">
<div class="legend-item"><div class="legend-line" style="background:#58a6ff"></div>Semantic</div>
<div class="legend-item"><div class="legend-line" style="background:#3fb950"></div>Temporal</div>
<div class="legend-item"><div class="legend-line" style="background:#f85149"></div>Causal</div>
<div class="legend-item"><div class="legend-line" style="background:#bc8cff"></div>Derived</div>
<div class="legend-item"><div class="legend-line" style="background:#f0883e"></div>Part-of</div>
<div class="legend-item"><div class="legend-line" style="background:#d29922"></div>Other</div>
</div>
</div>
</div>
<!-- Graph Canvas -->
<div class="graph-container" id="js-graph-container">
<div class="graph-loading" id="js-loading">
<span class="spinner"></span>
<span style="color:var(--text-secondary)">Loading graph...</span>
</div>
<div class="graph-overlay">
<div class="search-graph">
<span class="search-graph-icon"><svg width="14" height="14" viewBox="0 0 16 16" fill="currentColor"><path d="M11.5 7a4.5 4.5 0 11-9 0 4.5 4.5 0 019 0zm-.82 4.74a6 6 0 111.06-1.06l3.04 3.04a.75.75 0 11-1.06 1.06l-3.04-3.04z"/></svg></span>
<input type="text" id="js-search-node" placeholder="Find node..." />
</div>
<div class="zoom-controls">
<button id="js-zoom-in" title="Zoom in">+</button>
<button id="js-zoom-out" title="Zoom out">-</button>
<button id="js-zoom-fit" title="Fit to screen">&#x2922;</button>
</div>
</div>
<svg id="js-svg"></svg>
<div class="tooltip" id="js-tooltip">
<div class="tooltip-label" id="js-tooltip-label"></div>
<div class="tooltip-meta" id="js-tooltip-meta"></div>
</div>
</div>
<!-- Detail Panel -->
<div class="detail-panel" id="js-detail">
<div class="detail-empty" id="js-detail-empty">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><circle cx="12" cy="12" r="10"/><path d="M12 8v4M12 16h.01"/></svg>
<div style="font-size:13px;margin-top:4px">Click a node to inspect</div>
<div style="font-size:11px;color:var(--text-secondary);margin-top:4px">Drag to rearrange, scroll to zoom</div>
</div>
<div id="js-detail-content" style="display:none"></div>
</div>
</div>
<div class="toast-container" id="js-toasts"></div>
<script>
// ============================================================================
// VESTIGE MEMORY GRAPH — Inline d3-force simulation (no CDN dependency)
// ============================================================================
// Implements a minimal d3-force-compatible physics engine for force-directed
// graph layout. SVG rendering with glow filters for neural network aesthetic.
// ============================================================================
(function() {
"use strict";
// ── Node type color map ──
var TYPE_COLORS = {
fact: "#58a6ff",
concept: "#bc8cff",
event: "#3fb950",
person: "#f0883e",
pattern: "#39d2c0",
decision: "#d29922",
note: "#8b949e",
place: "#f85149",
code: "#58a6ff",
insight: "#bc8cff",
question: "#d29922",
quote: "#8b949e",
relationship: "#f0883e"
};
// ── Edge type color map ──
var EDGE_COLORS = {
semantic: "#58a6ff",
temporal: "#3fb950",
causal: "#f85149",
derived: "#bc8cff",
part_of: "#f0883e",
refinement: "#39d2c0",
contradiction: "#f85149",
spatial: "#d29922",
user_defined: "#8b949e"
};
// ── DOM References ──
var $svg = document.getElementById("js-svg");
var $container = document.getElementById("js-graph-container");
var $loading = document.getElementById("js-loading");
var $tooltip = document.getElementById("js-tooltip");
var $tooltipLabel = document.getElementById("js-tooltip-label");
var $tooltipMeta = document.getElementById("js-tooltip-meta");
var $detail = document.getElementById("js-detail");
var $detailEmpty = document.getElementById("js-detail-empty");
var $detailContent = document.getElementById("js-detail-content");
var $query = document.getElementById("js-query");
var $depth = document.getElementById("js-depth");
var $depthVal = document.getElementById("js-depth-val");
var $maxNodes = document.getElementById("js-max-nodes");
var $maxVal = document.getElementById("js-max-val");
var $btnLoad = document.getElementById("js-btn-load");
var $btnDream = document.getElementById("js-btn-dream");
var $btnExport = document.getElementById("js-btn-export");
var $filterType = document.getElementById("js-filter-type");
var $filterRet = document.getElementById("js-filter-ret");
var $retVal = document.getElementById("js-ret-val");
var $searchNode = document.getElementById("js-search-node");
var $nodeCount = document.getElementById("js-node-count");
var $edgeCount = document.getElementById("js-edge-count");
var $tagFilters = document.getElementById("js-tag-filters");
var $zoomIn = document.getElementById("js-zoom-in");
var $zoomOut = document.getElementById("js-zoom-out");
var $zoomFit = document.getElementById("js-zoom-fit");
var $toasts = document.getElementById("js-toasts");
// ── State ──
var graphData = { nodes: [], edges: [] };
var simulation = null;
var svgGroup = null;
var selectedNodeId = null;
var activeTags = new Set();
var transform = { x: 0, y: 0, k: 1 };
var isDragging = false;
var dragNode = null;
var dragOffset = { x: 0, y: 0 };
// ============================================================================
// MINIMAL FORCE SIMULATION ENGINE
// ============================================================================
// Implements Verlet integration with: many-body repulsion, link attraction,
// center gravity, and collision avoidance. No external library needed.
// ============================================================================
function ForceSimulation(nodes) {
this.nodes = nodes;
this.alpha = 1.0;
this.alphaMin = 0.001;
this.alphaDecay = 1 - Math.pow(this.alphaMin, 1 / 300);
this.alphaTarget = 0;
this.velocityDecay = 0.4;
this.forces = {};
this._running = false;
this._onTick = null;
this._onEnd = null;
this._frameId = null;
// Initialize positions if not set
var n = nodes.length;
for (var i = 0; i < n; i++) {
var node = nodes[i];
if (node.x == null) node.x = Math.cos(2 * Math.PI * i / n) * 200 + 400;
if (node.y == null) node.y = Math.sin(2 * Math.PI * i / n) * 200 + 300;
if (node.vx == null) node.vx = 0;
if (node.vy == null) node.vy = 0;
}
}
ForceSimulation.prototype.force = function(name, f) {
if (arguments.length === 1) return this.forces[name];
this.forces[name] = f;
return this;
};
ForceSimulation.prototype.on = function(event, fn) {
if (event === "tick") this._onTick = fn;
if (event === "end") this._onEnd = fn;
return this;
};
ForceSimulation.prototype.start = function() {
this.alpha = 1.0;
this._running = true;
this._step();
return this;
};
ForceSimulation.prototype.stop = function() {
this._running = false;
if (this._frameId) cancelAnimationFrame(this._frameId);
return this;
};
ForceSimulation.prototype.restart = function() {
this.alpha = Math.max(this.alpha, 0.3);
if (!this._running) {
this._running = true;
this._step();
}
return this;
};
ForceSimulation.prototype._step = function() {
var self = this;
this._frameId = requestAnimationFrame(function() {
self.tick();
if (self._onTick) self._onTick();
if (self.alpha < self.alphaMin) {
self._running = false;
if (self._onEnd) self._onEnd();
return;
}
if (self._running) self._step();
});
};
ForceSimulation.prototype.tick = function() {
this.alpha += (this.alphaTarget - this.alpha) * this.alphaDecay;
// Apply forces
for (var name in this.forces) {
this.forces[name](this.alpha, this.nodes);
}
// Verlet integration
var decay = 1 - this.velocityDecay;
for (var i = 0; i < this.nodes.length; i++) {
var n = this.nodes[i];
if (n.fx != null) { n.x = n.fx; n.vx = 0; }
else { n.vx *= decay; n.x += n.vx; }
if (n.fy != null) { n.y = n.fy; n.vy = 0; }
else { n.vy *= decay; n.y += n.vy; }
}
};
// ── Force: Many-body (Barnes-Hut approximation simplified) ──
function forceManyBody(strength) {
var s = strength || -120;
return function(alpha, nodes) {
for (var i = 0; i < nodes.length; i++) {
for (var j = i + 1; j < nodes.length; j++) {
var dx = nodes[j].x - nodes[i].x;
var dy = nodes[j].y - nodes[i].y;
var d2 = dx * dx + dy * dy;
if (d2 < 1) d2 = 1;
var d = Math.sqrt(d2);
var force = s * alpha / d2;
var fx = dx / d * force;
var fy = dy / d * force;
nodes[i].vx -= fx;
nodes[i].vy -= fy;
nodes[j].vx += fx;
nodes[j].vy += fy;
}
}
};
}
// ── Force: Links (spring attraction) ──
function forceLink(links, idFn) {
var strength = 0.3;
var distance = 80;
var resolvedLinks = [];
function resolve(nodes) {
var map = {};
for (var i = 0; i < nodes.length; i++) {
map[idFn ? idFn(nodes[i]) : nodes[i].id] = nodes[i];
}
resolvedLinks = [];
for (var j = 0; j < links.length; j++) {
var src = map[links[j].source];
var tgt = map[links[j].target];
if (src && tgt) {
resolvedLinks.push({ source: src, target: tgt, weight: links[j].weight || 1 });
}
}
}
var force = function(alpha, nodes) {
if (resolvedLinks.length === 0) resolve(nodes);
for (var i = 0; i < resolvedLinks.length; i++) {
var link = resolvedLinks[i];
var dx = link.target.x - link.source.x;
var dy = link.target.y - link.source.y;
var d = Math.sqrt(dx * dx + dy * dy) || 1;
var target_d = distance / Math.max(link.weight, 0.1);
var force_val = (d - target_d) / d * alpha * strength * link.weight;
var fx = dx * force_val;
var fy = dy * force_val;
link.source.vx += fx;
link.source.vy += fy;
link.target.vx -= fx;
link.target.vy -= fy;
}
};
force.resolve = resolve;
force.resolvedLinks = function() { return resolvedLinks; };
return force;
}
// ── Force: Center gravity ──
function forceCenter(cx, cy) {
return function(alpha, nodes) {
var sx = 0, sy = 0;
for (var i = 0; i < nodes.length; i++) {
sx += nodes[i].x;
sy += nodes[i].y;
}
sx = sx / nodes.length - cx;
sy = sy / nodes.length - cy;
for (var j = 0; j < nodes.length; j++) {
nodes[j].x -= sx * 0.1;
nodes[j].y -= sy * 0.1;
}
};
}
// ── Force: Collision avoidance ──
function forceCollide(radius) {
return function(alpha, nodes) {
for (var i = 0; i < nodes.length; i++) {
for (var j = i + 1; j < nodes.length; j++) {
var dx = nodes[j].x - nodes[i].x;
var dy = nodes[j].y - nodes[i].y;
var d = Math.sqrt(dx * dx + dy * dy) || 1;
var ri = typeof radius === "function" ? radius(nodes[i]) : radius;
var rj = typeof radius === "function" ? radius(nodes[j]) : radius;
var minDist = ri + rj;
if (d < minDist) {
var overlap = (minDist - d) / d * 0.5;
var ox = dx * overlap;
var oy = dy * overlap;
nodes[i].vx -= ox;
nodes[i].vy -= oy;
nodes[j].vx += ox;
nodes[j].vy += oy;
}
}
}
};
}
// ============================================================================
// SVG RENDERING
// ============================================================================
function initSvg() {
var w = $container.clientWidth;
var h = $container.clientHeight;
$svg.setAttribute("viewBox", "0 0 " + w + " " + h);
// Clear
$svg.innerHTML = "";
// Defs: glow filters
var defs = createSvgEl("defs");
// Node glow filter
var glowFilter = createSvgEl("filter");
glowFilter.setAttribute("id", "glow");
glowFilter.setAttribute("x", "-50%");
glowFilter.setAttribute("y", "-50%");
glowFilter.setAttribute("width", "200%");
glowFilter.setAttribute("height", "200%");
var feBlur = createSvgEl("feGaussianBlur");
feBlur.setAttribute("stdDeviation", "3");
feBlur.setAttribute("result", "coloredBlur");
var feMerge = createSvgEl("feMerge");
var feMergeNode1 = createSvgEl("feMergeNode");
feMergeNode1.setAttribute("in", "coloredBlur");
var feMergeNode2 = createSvgEl("feMergeNode");
feMergeNode2.setAttribute("in", "SourceGraphic");
feMerge.appendChild(feMergeNode1);
feMerge.appendChild(feMergeNode2);
glowFilter.appendChild(feBlur);
glowFilter.appendChild(feMerge);
defs.appendChild(glowFilter);
// Strong glow for center/selected
var glowStrong = createSvgEl("filter");
glowStrong.setAttribute("id", "glow-strong");
glowStrong.setAttribute("x", "-80%");
glowStrong.setAttribute("y", "-80%");
glowStrong.setAttribute("width", "260%");
glowStrong.setAttribute("height", "260%");
var feBlur2 = createSvgEl("feGaussianBlur");
feBlur2.setAttribute("stdDeviation", "6");
feBlur2.setAttribute("result", "coloredBlur");
var feMerge2 = createSvgEl("feMerge");
var fm2a = createSvgEl("feMergeNode");
fm2a.setAttribute("in", "coloredBlur");
var fm2b = createSvgEl("feMergeNode");
fm2b.setAttribute("in", "coloredBlur");
var fm2c = createSvgEl("feMergeNode");
fm2c.setAttribute("in", "SourceGraphic");
feMerge2.appendChild(fm2a);
feMerge2.appendChild(fm2b);
feMerge2.appendChild(fm2c);
glowStrong.appendChild(feBlur2);
glowStrong.appendChild(feMerge2);
defs.appendChild(glowStrong);
// Edge glow filter (subtle)
var edgeGlow = createSvgEl("filter");
edgeGlow.setAttribute("id", "edge-glow");
edgeGlow.setAttribute("x", "-20%");
edgeGlow.setAttribute("y", "-20%");
edgeGlow.setAttribute("width", "140%");
edgeGlow.setAttribute("height", "140%");
var feBlur3 = createSvgEl("feGaussianBlur");
feBlur3.setAttribute("stdDeviation", "1.5");
feBlur3.setAttribute("result", "blur");
var feMerge3 = createSvgEl("feMerge");
var fm3a = createSvgEl("feMergeNode");
fm3a.setAttribute("in", "blur");
var fm3b = createSvgEl("feMergeNode");
fm3b.setAttribute("in", "SourceGraphic");
feMerge3.appendChild(fm3a);
feMerge3.appendChild(fm3b);
edgeGlow.appendChild(feBlur3);
edgeGlow.appendChild(feMerge3);
defs.appendChild(edgeGlow);
$svg.appendChild(defs);
// Background pattern — subtle grid
var bgRect = createSvgEl("rect");
bgRect.setAttribute("width", "100%");
bgRect.setAttribute("height", "100%");
bgRect.setAttribute("fill", "#0d1117");
$svg.appendChild(bgRect);
// Main group for transform
svgGroup = createSvgEl("g");
svgGroup.setAttribute("id", "graph-group");
$svg.appendChild(svgGroup);
}
function createSvgEl(tag) {
return document.createElementNS("http://www.w3.org/2000/svg", tag);
}
function nodeRadius(node) {
// Scale radius by retention: min 6, max 20
return 6 + (node.retention || 0) * 14;
}
function nodeOpacity(node) {
// More recent = more opaque. Oldest = 0.35, newest = 1.0
if (!node._recency) return 0.85;
return 0.35 + node._recency * 0.65;
}
function edgeColor(type) {
return EDGE_COLORS[type] || EDGE_COLORS[(type || "").toLowerCase()] || "#30363d";
}
function nodeColor(type) {
return TYPE_COLORS[type] || TYPE_COLORS[(type || "").toLowerCase()] || "#8b949e";
}
function renderGraph() {
initSvg();
var nodes = graphData.nodes;
var edges = graphData.edges;
if (nodes.length === 0) {
$loading.classList.add("hidden");
return;
}
// Compute recency for opacity
var times = nodes.map(function(n) { return n._created || 0; });
var minT = Math.min.apply(null, times);
var maxT = Math.max.apply(null, times);
var rangeT = maxT - minT || 1;
nodes.forEach(function(n) {
n._recency = ((n._created || 0) - minT) / rangeT;
});
// Draw edges
var edgeGroup = createSvgEl("g");
edgeGroup.setAttribute("class", "edges");
edges.forEach(function(e) {
var line = createSvgEl("line");
line.setAttribute("class", "edge");
line.setAttribute("stroke", edgeColor(e.type));
line.setAttribute("stroke-width", Math.max(0.5, (e.weight || 0.5) * 2.5));
line.setAttribute("stroke-opacity", 0.4);
line.setAttribute("filter", "url(#edge-glow)");
line.dataset.source = e.source;
line.dataset.target = e.target;
edgeGroup.appendChild(line);
});
svgGroup.appendChild(edgeGroup);
// Draw nodes
var nodeGroup = createSvgEl("g");
nodeGroup.setAttribute("class", "nodes");
nodes.forEach(function(n) {
var g = createSvgEl("g");
g.setAttribute("class", "node");
g.dataset.id = n.id;
g.style.cursor = "pointer";
var r = nodeRadius(n);
var color = nodeColor(n.type);
var opacity = nodeOpacity(n);
var isCenter = n.isCenter;
// Outer ring for center node
if (isCenter) {
var ring = createSvgEl("circle");
ring.setAttribute("r", r + 4);
ring.setAttribute("fill", "none");
ring.setAttribute("stroke", color);
ring.setAttribute("stroke-width", "1.5");
ring.setAttribute("stroke-opacity", "0.5");
ring.setAttribute("stroke-dasharray", "4 2");
g.appendChild(ring);
}
// Main circle
var circle = createSvgEl("circle");
circle.setAttribute("r", r);
circle.setAttribute("fill", color);
circle.setAttribute("fill-opacity", opacity);
circle.setAttribute("stroke", color);
circle.setAttribute("stroke-width", isCenter ? "2" : "1");
circle.setAttribute("stroke-opacity", isCenter ? "0.9" : "0.6");
circle.setAttribute("filter", isCenter ? "url(#glow-strong)" : "url(#glow)");
g.appendChild(circle);
// Label (truncated)
var label = createSvgEl("text");
label.setAttribute("text-anchor", "middle");
label.setAttribute("dy", r + 14);
label.setAttribute("fill", "#c9d1d9");
label.setAttribute("fill-opacity", Math.max(0.5, opacity));
label.setAttribute("font-size", "10");
label.setAttribute("font-family", "-apple-system,BlinkMacSystemFont,Segoe UI,Helvetica,Arial,sans-serif");
label.textContent = truncate(n.label, 24);
g.appendChild(label);
nodeGroup.appendChild(g);
});
svgGroup.appendChild(nodeGroup);
// ── Set up simulation ──
var w = $container.clientWidth;
var h = $container.clientHeight;
var linkForce = forceLink(edges, function(n) { return n.id; });
linkForce.resolve(nodes);
simulation = new ForceSimulation(nodes);
simulation
.force("charge", forceManyBody(-150 - nodes.length * 0.5))
.force("link", linkForce)
.force("center", forceCenter(w / 2, h / 2))
.force("collide", forceCollide(function(n) { return nodeRadius(n) + 4; }))
.on("tick", function() {
// Update edge positions
var lines = edgeGroup.querySelectorAll(".edge");
var resolvedLinks = linkForce.resolvedLinks();
for (var i = 0; i < resolvedLinks.length; i++) {
var link = resolvedLinks[i];
if (lines[i]) {
lines[i].setAttribute("x1", link.source.x);
lines[i].setAttribute("y1", link.source.y);
lines[i].setAttribute("x2", link.target.x);
lines[i].setAttribute("y2", link.target.y);
}
}
// Update node positions
var nodeEls = nodeGroup.querySelectorAll(".node");
for (var j = 0; j < nodes.length; j++) {
if (nodeEls[j]) {
nodeEls[j].setAttribute("transform", "translate(" + nodes[j].x + "," + nodes[j].y + ")");
}
}
})
.start();
// ── Interactions ──
setupInteractions(nodeGroup, edgeGroup, nodes);
$loading.classList.add("hidden");
$nodeCount.textContent = nodes.length;
$edgeCount.textContent = edges.length;
// Collect tags for filter
var allTags = new Set();
nodes.forEach(function(n) {
(n.tags || []).forEach(function(t) { allTags.add(t); });
});
renderTagFilters(Array.from(allTags));
}
// ============================================================================
// INTERACTIONS (drag, hover, click, zoom, pan)
// ============================================================================
function setupInteractions(nodeGroup, edgeGroup, nodes) {
var nodeEls = nodeGroup.querySelectorAll(".node");
// ── Hover tooltip ──
nodeEls.forEach(function(el) {
el.addEventListener("mouseenter", function(e) {
var id = el.dataset.id;
var node = nodes.find(function(n) { return n.id === id; });
if (!node) return;
$tooltipLabel.textContent = truncate(node.label, 80);
$tooltipMeta.textContent = (node.type || "unknown") + " | retention: " + Math.round((node.retention || 0) * 100) + "%";
$tooltip.classList.add("visible");
// Highlight connected edges
var lines = edgeGroup.querySelectorAll(".edge");
lines.forEach(function(line) {
if (line.dataset.source === id || line.dataset.target === id) {
line.setAttribute("stroke-opacity", "0.9");
line.setAttribute("stroke-width", parseFloat(line.getAttribute("stroke-width")) * 1.5);
}
});
});
el.addEventListener("mousemove", function(e) {
var rect = $container.getBoundingClientRect();
$tooltip.style.left = (e.clientX - rect.left + 12) + "px";
$tooltip.style.top = (e.clientY - rect.top - 10) + "px";
});
el.addEventListener("mouseleave", function() {
$tooltip.classList.remove("visible");
// Reset edge highlights
var lines = edgeGroup.querySelectorAll(".edge");
lines.forEach(function(line) {
line.setAttribute("stroke-opacity", "0.4");
var e = graphData.edges.find(function(ed) {
return ed.source === line.dataset.source && ed.target === line.dataset.target;
});
line.setAttribute("stroke-width", Math.max(0.5, ((e && e.weight) || 0.5) * 2.5));
});
});
// ── Click to select ──
el.addEventListener("click", function(e) {
if (isDragging) return;
var id = el.dataset.id;
selectNode(id, nodes);
e.stopPropagation();
});
// ── Drag ──
el.addEventListener("mousedown", function(e) {
if (e.button !== 0) return;
var id = el.dataset.id;
dragNode = nodes.find(function(n) { return n.id === id; });
if (!dragNode) return;
isDragging = false;
var pt = svgPoint(e);
dragOffset.x = pt.x - dragNode.x;
dragOffset.y = pt.y - dragNode.y;
dragNode.fx = dragNode.x;
dragNode.fy = dragNode.y;
if (simulation) simulation.restart();
e.preventDefault();
e.stopPropagation();
});
});
// Global drag/pan handlers
$svg.addEventListener("mousemove", function(e) {
if (dragNode) {
isDragging = true;
var pt = svgPoint(e);
dragNode.fx = pt.x - dragOffset.x;
dragNode.fy = pt.y - dragOffset.y;
if (simulation) simulation.restart();
}
});
$svg.addEventListener("mouseup", function() {
if (dragNode) {
if (!isDragging) {
// It was a click, not a drag
}
dragNode.fx = null;
dragNode.fy = null;
dragNode = null;
setTimeout(function() { isDragging = false; }, 50);
}
});
// ── Click background to deselect ──
$svg.addEventListener("click", function(e) {
if (e.target === $svg || e.target.tagName === "rect") {
deselectNode();
}
});
// ── Zoom (wheel) ──
$svg.addEventListener("wheel", function(e) {
e.preventDefault();
var delta = e.deltaY > 0 ? 0.9 : 1.1;
var rect = $container.getBoundingClientRect();
var cx = e.clientX - rect.left;
var cy = e.clientY - rect.top;
zoomAt(cx, cy, delta);
}, { passive: false });
}
function svgPoint(e) {
var rect = $svg.getBoundingClientRect();
var svgW = $svg.viewBox.baseVal.width || $container.clientWidth;
var svgH = $svg.viewBox.baseVal.height || $container.clientHeight;
var scaleX = svgW / rect.width;
var scaleY = svgH / rect.height;
var x = (e.clientX - rect.left) * scaleX;
var y = (e.clientY - rect.top) * scaleY;
// Invert the group transform
return { x: (x - transform.x) / transform.k, y: (y - transform.y) / transform.k };
}
function applyTransform() {
if (svgGroup) {
svgGroup.setAttribute("transform",
"translate(" + transform.x + "," + transform.y + ") scale(" + transform.k + ")");
}
}
function zoomAt(cx, cy, factor) {
var newK = Math.max(0.1, Math.min(5, transform.k * factor));
var ratio = newK / transform.k;
transform.x = cx - (cx - transform.x) * ratio;
transform.y = cy - (cy - transform.y) * ratio;
transform.k = newK;
applyTransform();
}
function zoomToFit() {
if (!graphData.nodes.length) return;
var w = $container.clientWidth;
var h = $container.clientHeight;
var minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
graphData.nodes.forEach(function(n) {
var r = nodeRadius(n);
if (n.x - r < minX) minX = n.x - r;
if (n.y - r < minY) minY = n.y - r;
if (n.x + r > maxX) maxX = n.x + r;
if (n.y + r > maxY) maxY = n.y + r;
});
var gw = maxX - minX + 80;
var gh = maxY - minY + 80;
var scale = Math.min(w / gw, h / gh, 2);
transform.k = scale;
transform.x = (w - gw * scale) / 2 - minX * scale + 40 * scale;
transform.y = (h - gh * scale) / 2 - minY * scale + 40 * scale;
applyTransform();
}
// ============================================================================
// NODE SELECTION & DETAIL PANEL
// ============================================================================
function selectNode(id, nodes) {
selectedNodeId = id;
var node = nodes.find(function(n) { return n.id === id; });
if (!node) return;
// Highlight selected node
var allNodeEls = document.querySelectorAll(".node");
allNodeEls.forEach(function(el) {
var c = el.querySelector("circle");
if (el.dataset.id === id) {
c.setAttribute("filter", "url(#glow-strong)");
c.setAttribute("stroke-width", "2.5");
} else {
c.setAttribute("filter", "url(#glow)");
c.setAttribute("stroke-width", "1");
}
});
// Find connections
var connections = graphData.edges.filter(function(e) {
return e.source === id || e.target === id;
});
var connNodes = connections.map(function(e) {
var otherId = e.source === id ? e.target : e.source;
var other = nodes.find(function(n) { return n.id === otherId; });
return { edge: e, node: other };
}).filter(function(c) { return c.node; });
// Render detail
var typeClass = "type-" + (node.type || "note");
var retPct = Math.round((node.retention || 0) * 100);
var retColor = retPct >= 70 ? "var(--green)" : retPct >= 40 ? "var(--yellow)" : "var(--red)";
var html = "";
html += '<div class="detail-header">';
html += '<span class="detail-type ' + typeClass + '">' + esc(node.type || "unknown") + '</span>';
html += '<div class="detail-id">' + esc(node.id) + '</div>';
html += '</div>';
html += '<div class="detail-content">' + esc(node.label) + '</div>';
html += '<div class="detail-section"><div class="detail-section-title">Memory Strength</div>';
html += '<div class="detail-grid">';
html += '<div class="detail-field"><div class="detail-field-label">Retention</div><div class="detail-field-value" style="color:' + retColor + '">' + retPct + '%</div></div>';
html += '<div class="detail-field"><div class="detail-field-label">Connections</div><div class="detail-field-value" style="color:var(--accent)">' + connections.length + '</div></div>';
html += '</div></div>';
if (node.tags && node.tags.length > 0) {
html += '<div class="detail-section"><div class="detail-section-title">Tags</div>';
html += '<div class="detail-tags">';
node.tags.forEach(function(t) {
html += '<span class="detail-tag">' + esc(t) + '</span>';
});
html += '</div></div>';
}
if (connNodes.length > 0) {
html += '<div class="detail-section"><div class="detail-section-title">Connections (' + connNodes.length + ')</div>';
html += '<div class="detail-connections">';
connNodes.forEach(function(c) {
var color = nodeColor(c.node.type);
html += '<div class="conn-item" data-id="' + escAttr(c.node.id) + '">';
html += '<div class="conn-dot" style="background:' + color + '"></div>';
html += '<div class="conn-label" title="' + escAttr(c.node.label) + '">' + esc(truncate(c.node.label, 32)) + '</div>';
html += '<div class="conn-strength">' + (c.edge.type || "?") + ' ' + Math.round((c.edge.weight || 0) * 100) + '%</div>';
html += '</div>';
});
html += '</div></div>';
}
html += '<div class="detail-actions">';
html += '<button class="btn btn-accent" onclick="window._centerOnNode(\'' + escAttr(node.id) + '\')">Center Graph</button>';
html += '<a class="btn" href="/api/memories/' + encodeURIComponent(node.id) + '" target="_blank">View JSON</a>';
html += '</div>';
$detailContent.innerHTML = html;
$detailContent.style.display = "block";
$detailEmpty.style.display = "none";
// Click connections to navigate
var connEls = $detailContent.querySelectorAll(".conn-item");
connEls.forEach(function(el) {
el.addEventListener("click", function() {
selectNode(el.dataset.id, nodes);
});
});
}
function deselectNode() {
selectedNodeId = null;
$detailContent.style.display = "none";
$detailEmpty.style.display = "flex";
var allNodeEls = document.querySelectorAll(".node");
allNodeEls.forEach(function(el) {
var c = el.querySelector("circle");
var node = graphData.nodes.find(function(n) { return n.id === el.dataset.id; });
if (node && node.isCenter) {
c.setAttribute("filter", "url(#glow-strong)");
} else {
c.setAttribute("filter", "url(#glow)");
c.setAttribute("stroke-width", "1");
}
});
}
// Expose for inline onclick
window._centerOnNode = function(id) {
$query.value = "";
loadGraph(id);
};
// ============================================================================
// DATA LOADING
// ============================================================================
function loadGraph(centerId) {
var query = $query.value.trim();
var depth = parseInt($depth.value) || 2;
var maxNodes = parseInt($maxNodes.value) || 50;
var params = "depth=" + depth + "&max_nodes=" + maxNodes;
if (centerId) {
params += "&center_id=" + encodeURIComponent(centerId);
} else if (query) {
params += "&query=" + encodeURIComponent(query);
}
$loading.classList.remove("hidden");
fetch("/api/graph?" + params)
.then(function(r) {
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
})
.then(function(data) {
// Parse timestamps for recency
data.nodes.forEach(function(n) {
n._created = n.createdAt ? new Date(n.createdAt).getTime() : Date.now();
});
graphData = data;
transform = { x: 0, y: 0, k: 1 };
selectedNodeId = null;
$detailContent.style.display = "none";
$detailEmpty.style.display = "flex";
renderGraph();
// Fit after simulation settles a bit
setTimeout(zoomToFit, 800);
})
.catch(function(err) {
$loading.classList.add("hidden");
showToast("Failed to load graph: " + err.message, "error");
});
}
function applyFilters() {
if (!graphData.nodes.length) return;
var typeFilter = $filterType.value;
var retFilter = parseInt($filterRet.value) / 100;
document.querySelectorAll(".node").forEach(function(el) {
var id = el.dataset.id;
var node = graphData.nodes.find(function(n) { return n.id === id; });
if (!node) return;
var visible = true;
if (typeFilter && node.type !== typeFilter) visible = false;
if (node.retention < retFilter) visible = false;
if (activeTags.size > 0) {
var hasTag = (node.tags || []).some(function(t) { return activeTags.has(t); });
if (!hasTag) visible = false;
}
el.style.opacity = visible ? "1" : "0.08";
el.style.pointerEvents = visible ? "auto" : "none";
});
// Dim edges to hidden nodes
document.querySelectorAll(".edge").forEach(function(line) {
var srcEl = document.querySelector('.node[data-id="' + line.dataset.source + '"]');
var tgtEl = document.querySelector('.node[data-id="' + line.dataset.target + '"]');
var srcVis = srcEl && srcEl.style.opacity !== "0.08";
var tgtVis = tgtEl && tgtEl.style.opacity !== "0.08";
line.style.opacity = (srcVis && tgtVis) ? "1" : "0.05";
});
}
// ============================================================================
// SEARCH NODE (in-graph find)
// ============================================================================
function searchInGraph(query) {
if (!query) {
document.querySelectorAll(".node").forEach(function(el) { el.style.opacity = "1"; });
return;
}
var q = query.toLowerCase();
var found = null;
document.querySelectorAll(".node").forEach(function(el) {
var id = el.dataset.id;
var node = graphData.nodes.find(function(n) { return n.id === id; });
if (!node) return;
var match = (node.label || "").toLowerCase().indexOf(q) >= 0 ||
(node.id || "").toLowerCase().indexOf(q) >= 0 ||
(node.tags || []).some(function(t) { return t.toLowerCase().indexOf(q) >= 0; });
el.style.opacity = match ? "1" : "0.1";
if (match && !found) found = node;
});
// Pan to first match
if (found) {
var w = $container.clientWidth;
var h = $container.clientHeight;
transform.x = w / 2 - found.x * transform.k;
transform.y = h / 2 - found.y * transform.k;
applyTransform();
}
}
// ============================================================================
// DREAM REPLAY
// ============================================================================
function dreamReplay() {
showToast("Dream replay: animating connections...", "info");
var edges = document.querySelectorAll(".edge");
var nodeEls = document.querySelectorAll(".node");
var i = 0;
var interval = setInterval(function() {
if (i >= edges.length) {
clearInterval(interval);
// Reset
edges.forEach(function(e) {
e.setAttribute("stroke-opacity", "0.4");
e.style.transition = "";
});
nodeEls.forEach(function(n) { n.classList.remove("dream-pulse"); });
showToast("Dream replay complete", "success");
return;
}
var edge = edges[i];
edge.style.transition = "stroke-opacity 0.3s";
edge.setAttribute("stroke-opacity", "1");
edge.setAttribute("stroke-width", "3");
// Pulse connected nodes
var srcNode = document.querySelector('.node[data-id="' + edge.dataset.source + '"]');
var tgtNode = document.querySelector('.node[data-id="' + edge.dataset.target + '"]');
if (srcNode) srcNode.classList.add("dream-pulse");
if (tgtNode) tgtNode.classList.add("dream-pulse");
// Fade previous
if (i > 0) {
var prev = edges[i - 1];
prev.setAttribute("stroke-opacity", "0.6");
prev.setAttribute("stroke-width", "2");
}
i++;
}, 150);
}
// ============================================================================
// EXPORT PNG
// ============================================================================
function exportPng() {
var svgData = new XMLSerializer().serializeToString($svg);
var svgBlob = new Blob([svgData], { type: "image/svg+xml;charset=utf-8" });
var url = URL.createObjectURL(svgBlob);
var img = new Image();
img.onload = function() {
var canvas = document.createElement("canvas");
var scale = 2; // High DPI
canvas.width = $svg.clientWidth * scale;
canvas.height = $svg.clientHeight * scale;
var ctx = canvas.getContext("2d");
ctx.scale(scale, scale);
ctx.fillStyle = "#0d1117";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, 0, 0, $svg.clientWidth, $svg.clientHeight);
canvas.toBlob(function(blob) {
var a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = "vestige-graph-" + new Date().toISOString().slice(0, 10) + ".png";
a.click();
URL.revokeObjectURL(a.href);
URL.revokeObjectURL(url);
showToast("Graph exported as PNG", "success");
}, "image/png");
};
img.onerror = function() {
showToast("PNG export failed", "error");
URL.revokeObjectURL(url);
};
img.src = url;
}
// ============================================================================
// TAG FILTERS
// ============================================================================
function renderTagFilters(tags) {
tags.sort();
$tagFilters.innerHTML = "";
if (tags.length === 0) {
$tagFilters.innerHTML = '<span style="font-size:11px;color:var(--text-secondary)">No tags</span>';
return;
}
tags.slice(0, 30).forEach(function(tag) {
var chip = document.createElement("span");
chip.className = "tag-chip" + (activeTags.has(tag) ? " active" : "");
chip.textContent = tag;
chip.addEventListener("click", function() {
if (activeTags.has(tag)) {
activeTags.delete(tag);
chip.classList.remove("active");
} else {
activeTags.add(tag);
chip.classList.add("active");
}
applyFilters();
});
$tagFilters.appendChild(chip);
});
}
// ============================================================================
// UTILITIES
// ============================================================================
function esc(s) {
if (s == null) return "";
var d = document.createElement("div");
d.textContent = String(s);
return d.innerHTML;
}
function escAttr(s) {
if (s == null) return "";
return String(s).replace(/&/g,"&amp;").replace(/"/g,"&quot;").replace(/'/g,"&#39;").replace(/</g,"&lt;").replace(/>/g,"&gt;");
}
function truncate(s, n) {
if (!s) return "";
return s.length > n ? s.substring(0, n) + "..." : s;
}
function showToast(msg, type) {
var el = document.createElement("div");
el.className = "toast " + (type || "info");
el.textContent = msg;
$toasts.appendChild(el);
requestAnimationFrame(function() {
requestAnimationFrame(function() { el.classList.add("visible"); });
});
setTimeout(function() {
el.classList.remove("visible");
setTimeout(function() { el.remove(); }, 300);
}, 3000);
}
// ============================================================================
// EVENT LISTENERS
// ============================================================================
$depth.addEventListener("input", function() { $depthVal.textContent = $depth.value; });
$maxNodes.addEventListener("input", function() { $maxVal.textContent = $maxNodes.value; });
$filterRet.addEventListener("input", function() {
$retVal.textContent = $filterRet.value + "%";
applyFilters();
});
$filterType.addEventListener("change", function() { applyFilters(); });
$btnLoad.addEventListener("click", function() { loadGraph(); });
$query.addEventListener("keydown", function(e) {
if (e.key === "Enter") loadGraph();
});
$btnDream.addEventListener("click", dreamReplay);
$btnExport.addEventListener("click", exportPng);
$zoomIn.addEventListener("click", function() {
var w = $container.clientWidth;
var h = $container.clientHeight;
zoomAt(w / 2, h / 2, 1.3);
});
$zoomOut.addEventListener("click", function() {
var w = $container.clientWidth;
var h = $container.clientHeight;
zoomAt(w / 2, h / 2, 0.7);
});
$zoomFit.addEventListener("click", zoomToFit);
var searchTimer = null;
$searchNode.addEventListener("input", function() {
clearTimeout(searchTimer);
searchTimer = setTimeout(function() {
searchInGraph($searchNode.value);
}, 200);
});
// Keyboard shortcuts
document.addEventListener("keydown", function(e) {
if (e.key === "Escape") deselectNode();
if (e.key === "/" && document.activeElement.tagName !== "INPUT" && document.activeElement.tagName !== "SELECT") {
$searchNode.focus();
e.preventDefault();
}
});
// Window resize
window.addEventListener("resize", function() {
if (graphData.nodes.length > 0) {
var w = $container.clientWidth;
var h = $container.clientHeight;
$svg.setAttribute("viewBox", "0 0 " + w + " " + h);
}
});
// ============================================================================
// INITIALIZE
// ============================================================================
// Parse URL params
var urlParams = new URLSearchParams(window.location.search);
var initQuery = urlParams.get("q") || urlParams.get("query") || "";
var initCenter = urlParams.get("center_id") || "";
if (initQuery) $query.value = initQuery;
// Load graph on page load
if (initCenter) {
loadGraph(initCenter);
} else {
loadGraph();
}
})();
</script>
</body>
</html>