mirror of
https://github.com/samvallad33/vestige.git
synced 2026-06-30 21:59:39 +02:00
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>
1437 lines
54 KiB
HTML
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">⤢</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 += "¢er_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,"&").replace(/"/g,""").replace(/'/g,"'").replace(/</g,"<").replace(/>/g,">");
|
|
}
|
|
|
|
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>
|