Star-Mapper/templates/index.html

1888 lines
60 KiB
HTML
Raw Normal View History

2026-03-06 16:34:01 +00:00
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Cortex Graph Explorer</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<script src="https://d3js.org/d3.v7.min.js"></script>
<style>
/* =========================================================
CSS — Cortex Graph Explorer
Dark theme with glass-morphism, glow accents
========================================================= */
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg-deep: #060a1a;
--bg-surface: rgba(12, 16, 38, 0.85);
--bg-card: rgba(255,255,255,0.04);
--bg-card-hover: rgba(255,255,255,0.07);
--border: rgba(255,255,255,0.08);
--border-glow:rgba(0,212,255,0.25);
--text: #e2e8f0;
--text-dim: #7a8ba8;
--text-muted: #4a5568;
--accent: #00d4ff;
--accent-dim: rgba(0,212,255,0.15);
--danger: #ff6b6b;
--success: #6bcb77;
--warn: #ffd93d;
--purple: #9b59b6;
--radius: 12px;
--radius-sm: 8px;
--font: 'Inter', -apple-system, sans-serif;
--mono: 'JetBrains Mono', 'Fira Code', monospace;
--sidebar-w: 380px;
--transition: 0.25s cubic-bezier(.4,0,.2,1);
}
html, body { height: 100%; overflow: hidden; }
body {
font-family: var(--font);
background: var(--bg-deep);
color: var(--text);
display: flex;
position: relative;
}
/* ---- Sidebar ---- */
#sidebar {
width: var(--sidebar-w);
min-width: var(--sidebar-w);
height: 100vh;
display: flex;
flex-direction: column;
background: var(--bg-surface);
backdrop-filter: blur(24px) saturate(1.4);
border-right: 1px solid var(--border);
z-index: 10;
overflow: hidden;
transition: transform var(--transition);
}
#sidebar.collapsed { transform: translateX(calc(-1 * var(--sidebar-w) + 36px)); }
.sidebar-header {
padding: 20px 20px 14px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
gap: 12px;
}
.sidebar-header .logo {
width: 32px; height: 32px;
border-radius: 50%;
background: linear-gradient(135deg, var(--accent), var(--purple));
display: flex; align-items: center; justify-content: center;
font-weight: 700; font-size: 15px; color: #fff;
box-shadow: 0 0 20px rgba(0,212,255,0.3);
}
.sidebar-header h1 { font-size: 16px; font-weight: 600; letter-spacing: -.3px; }
.sidebar-header .subtitle { font-size: 11px; color: var(--text-dim); margin-top: 2px; }
.sidebar-scroll {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
padding: 16px;
scrollbar-width: thin;
scrollbar-color: var(--border) transparent;
}
.sidebar-scroll::-webkit-scrollbar { width: 5px; }
.sidebar-scroll::-webkit-scrollbar-track { background: transparent; }
.sidebar-scroll::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
/* ---- Panels / Cards ---- */
.panel {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius);
margin-bottom: 12px;
overflow: hidden;
}
.panel-head {
padding: 10px 14px;
font-size: 11px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: .8px;
color: var(--text-dim);
cursor: pointer;
display: flex;
align-items: center;
justify-content: space-between;
user-select: none;
}
.panel-head .arrow { transition: transform var(--transition); font-size: 10px; }
.panel-head.collapsed .arrow { transform: rotate(-90deg); }
.panel-body { padding: 0 14px 14px; }
.panel-body.collapsed { display: none; }
/* ---- Connection indicator ---- */
#connection-status {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: var(--radius-sm);
font-size: 12px;
font-weight: 500;
margin-bottom: 12px;
background: var(--bg-card);
border: 1px solid var(--border);
}
#connection-status .dot {
width: 8px; height: 8px;
border-radius: 50%;
background: var(--text-muted);
transition: background var(--transition), box-shadow var(--transition);
}
#connection-status.ok .dot { background: var(--success); box-shadow: 0 0 8px var(--success); }
#connection-status.err .dot { background: var(--danger); box-shadow: 0 0 8px var(--danger); }
/* ---- Query editor ---- */
#query-editor {
width: 100%;
min-height: 120px;
max-height: 300px;
resize: vertical;
background: rgba(0,0,0,0.35);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 12px;
color: var(--text);
font-family: var(--mono);
font-size: 12.5px;
line-height: 1.6;
outline: none;
transition: border-color var(--transition), box-shadow var(--transition);
tab-size: 2;
}
#query-editor:focus {
border-color: var(--accent);
box-shadow: 0 0 0 3px var(--accent-dim);
}
#query-editor::placeholder { color: var(--text-muted); }
/* ---- Controls row ---- */
.controls-row {
display: flex;
gap: 8px;
margin-top: 10px;
flex-wrap: wrap;
}
/* ---- Buttons ---- */
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 16px;
border: none;
border-radius: var(--radius-sm);
font-family: var(--font);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all var(--transition);
white-space: nowrap;
}
.btn-primary {
background: linear-gradient(135deg, var(--accent), #0099cc);
color: #000;
box-shadow: 0 2px 12px rgba(0,212,255,0.3);
}
.btn-primary:hover { box-shadow: 0 4px 20px rgba(0,212,255,0.5); transform: translateY(-1px); }
.btn-primary:active { transform: translateY(0); }
.btn-primary:disabled { opacity: .5; cursor: not-allowed; transform: none; box-shadow: none; }
.btn-secondary {
background: var(--bg-card);
color: var(--text);
border: 1px solid var(--border);
}
.btn-secondary:hover { background: var(--bg-card-hover); border-color: var(--border-glow); }
.btn-sm { padding: 5px 10px; font-size: 11px; }
/* ---- Select ---- */
.styled-select {
background: rgba(0,0,0,0.35);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 7px 10px;
color: var(--text);
font-family: var(--font);
font-size: 12px;
outline: none;
cursor: pointer;
flex: 1;
min-width: 0;
}
.styled-select:focus { border-color: var(--accent); }
.styled-select option { background: #141428; color: var(--text); }
/* ---- Sample queries ---- */
.sample-query {
display: block;
padding: 8px 10px;
margin-bottom: 4px;
font-size: 12px;
color: var(--text-dim);
border-radius: var(--radius-sm);
cursor: pointer;
transition: all var(--transition);
border: 1px solid transparent;
}
.sample-query:hover {
background: var(--accent-dim);
color: var(--accent);
border-color: var(--border-glow);
}
/* ---- Schema tags ---- */
.tag-list { display: flex; flex-wrap: wrap; gap: 5px; }
.tag {
display: inline-block;
padding: 3px 9px;
font-size: 11px;
border-radius: 20px;
background: var(--accent-dim);
color: var(--accent);
border: 1px solid rgba(0,212,255,0.15);
font-family: var(--mono);
cursor: default;
}
.tag.rel {
background: rgba(255,107,107,0.12);
color: var(--danger);
border-color: rgba(255,107,107,0.15);
}
/* ---- Stats bar ---- */
#stats-bar {
padding: 10px 14px;
font-size: 11px;
color: var(--text-dim);
border-top: 1px solid var(--border);
display: flex;
flex-wrap: wrap;
gap: 12px;
}
#stats-bar .stat-val { color: var(--accent); font-weight: 600; font-family: var(--mono); }
/* ---- Legend ---- */
.legend-item {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
font-size: 12px;
color: var(--text-dim);
}
.legend-dot {
width: 10px; height: 10px;
border-radius: 50%;
flex-shrink: 0;
}
/* ---- Graph area ---- */
#graph-area {
flex: 1;
position: relative;
overflow: hidden;
background: radial-gradient(ellipse at 50% 50%, #0e1230 0%, #060a1a 70%);
}
#graph-canvas {
position: absolute;
top: 0; left: 0;
width: 100%; height: 100%;
cursor: grab;
}
#graph-canvas:active { cursor: grabbing; }
/* ---- Overlay controls ---- */
.graph-controls {
position: absolute;
bottom: 20px;
right: 20px;
display: flex;
flex-direction: column;
gap: 4px;
z-index: 5;
}
.graph-controls button {
width: 36px; height: 36px;
border: 1px solid var(--border);
background: var(--bg-surface);
backdrop-filter: blur(12px);
color: var(--text);
border-radius: var(--radius-sm);
font-size: 16px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all var(--transition);
}
.graph-controls button:hover {
background: var(--bg-card-hover);
border-color: var(--accent);
color: var(--accent);
}
/* ---- Tooltip ---- */
#tooltip {
position: absolute;
pointer-events: none;
background: var(--bg-surface);
backdrop-filter: blur(16px);
border: 1px solid var(--border-glow);
border-radius: var(--radius);
padding: 12px 16px;
max-width: 360px;
min-width: 180px;
font-size: 12px;
z-index: 20;
opacity: 0;
transform: translateY(4px);
transition: opacity .15s, transform .15s;
box-shadow: 0 8px 32px rgba(0,0,0,0.5), 0 0 20px rgba(0,212,255,0.08);
}
#tooltip.visible { opacity: 1; transform: translateY(0); }
#tooltip .tt-label { font-weight: 600; font-size: 14px; margin-bottom: 4px; }
#tooltip .tt-labels { margin-bottom: 6px; }
#tooltip .tt-props { color: var(--text-dim); line-height: 1.7; }
#tooltip .tt-props .key { color: var(--accent); font-family: var(--mono); font-size: 11px; }
#tooltip .tt-props .val { color: var(--text); }
/* ---- Loading overlay ---- */
#loading-overlay {
position: absolute;
inset: 0;
background: rgba(6,10,26,0.85);
backdrop-filter: blur(8px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
z-index: 30;
opacity: 0;
pointer-events: none;
transition: opacity .3s;
}
#loading-overlay.active { opacity: 1; pointer-events: all; }
.spinner {
width: 40px; height: 40px;
border: 3px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin .8s linear infinite;
margin-bottom: 14px;
}
@keyframes spin { to { transform: rotate(360deg); } }
#loading-text { font-size: 13px; color: var(--text-dim); }
/* ---- Table view ---- */
#table-view {
display: none;
position: absolute;
inset: 0;
background: var(--bg-deep);
z-index: 8;
overflow: auto;
padding: 20px;
}
#table-view.active { display: block; }
#table-view table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
#table-view th {
padding: 10px 14px;
text-align: left;
font-weight: 600;
color: var(--accent);
border-bottom: 2px solid var(--border);
position: sticky;
top: 0;
background: var(--bg-deep);
font-family: var(--mono);
font-size: 11px;
text-transform: uppercase;
letter-spacing: .5px;
}
#table-view td {
padding: 8px 14px;
border-bottom: 1px solid var(--border);
color: var(--text-dim);
max-width: 300px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--mono);
font-size: 11px;
}
#table-view tr:hover td { background: var(--bg-card); color: var(--text); }
/* ---- View toggle ---- */
.view-toggle {
position: absolute;
top: 16px;
left: 50%;
transform: translateX(-50%);
display: flex;
background: var(--bg-surface);
backdrop-filter: blur(12px);
border: 1px solid var(--border);
border-radius: 20px;
overflow: hidden;
z-index: 9;
}
.view-toggle button {
padding: 7px 18px;
border: none;
background: transparent;
color: var(--text-dim);
font-family: var(--font);
font-size: 12px;
font-weight: 500;
cursor: pointer;
transition: all var(--transition);
}
.view-toggle button.active {
background: var(--accent-dim);
color: var(--accent);
}
.view-toggle button:hover:not(.active) { color: var(--text); }
/* ---- Search box ---- */
#node-search {
width: 100%;
padding: 8px 12px;
background: rgba(0,0,0,0.3);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
font-family: var(--font);
font-size: 12px;
outline: none;
margin-bottom: 8px;
transition: border-color var(--transition);
}
#node-search:focus { border-color: var(--accent); }
#node-search::placeholder { color: var(--text-muted); }
/* ---- Sidebar toggle ---- */
#sidebar-toggle {
position: absolute;
top: 16px;
left: 16px;
z-index: 15;
width: 32px; height: 32px;
border: 1px solid var(--border);
background: var(--bg-surface);
backdrop-filter: blur(12px);
color: var(--text);
border-radius: var(--radius-sm);
cursor: pointer;
display: none;
align-items: center;
justify-content: center;
font-size: 14px;
}
#sidebar.collapsed ~ #graph-area #sidebar-toggle { display: flex; }
/* ---- Error toast ---- */
#error-toast {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%) translateY(20px);
background: rgba(255,107,107,0.15);
border: 1px solid var(--danger);
color: var(--danger);
padding: 12px 24px;
border-radius: var(--radius);
font-size: 13px;
z-index: 100;
opacity: 0;
transition: opacity .3s, transform .3s;
max-width: 600px;
text-align: center;
backdrop-filter: blur(12px);
}
#error-toast.visible { opacity: 1; transform: translateX(-50%) translateY(0); }
/* ---- Minimap ---- */
#minimap {
position: absolute;
bottom: 20px;
left: 20px;
width: 160px;
height: 120px;
background: rgba(6,10,26,0.7);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
z-index: 5;
overflow: hidden;
}
#minimap-canvas { width: 100%; height: 100%; }
/* ---- Range sliders ---- */
.slider-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.slider-row label {
font-size: 11px;
color: var(--text-dim);
min-width: 90px;
white-space: nowrap;
}
.slider-row input[type="range"] {
flex: 1;
-webkit-appearance: none;
appearance: none;
height: 4px;
background: var(--border);
border-radius: 2px;
outline: none;
cursor: pointer;
}
.slider-row input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px; height: 14px;
border-radius: 50%;
background: var(--accent);
box-shadow: 0 0 8px rgba(0,212,255,0.4);
cursor: pointer;
transition: box-shadow .2s;
}
.slider-row input[type="range"]::-webkit-slider-thumb:hover {
box-shadow: 0 0 14px rgba(0,212,255,0.7);
}
.slider-row input[type="range"]::-moz-range-thumb {
width: 14px; height: 14px;
border-radius: 50%;
background: var(--accent);
border: none;
box-shadow: 0 0 8px rgba(0,212,255,0.4);
cursor: pointer;
}
.slider-row .slider-val {
font-family: var(--mono);
font-size: 11px;
color: var(--accent);
min-width: 32px;
text-align: right;
}
.color-row {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 10px;
}
.color-row label {
font-size: 11px;
color: var(--text-dim);
min-width: 90px;
}
.color-row input[type="color"] {
-webkit-appearance: none;
border: 1px solid var(--border);
border-radius: 6px;
width: 32px; height: 24px;
padding: 0;
cursor: pointer;
background: transparent;
}
.color-row input[type="color"]::-webkit-color-swatch-wrapper { padding: 2px; }
.color-row input[type="color"]::-webkit-color-swatch { border: none; border-radius: 4px; }
.color-row select {
flex: 1;
}
/* ---- Responsive ---- */
@media (max-width: 900px) {
:root { --sidebar-w: 320px; }
}
</style>
</head>
<body>
<!-- ============ SIDEBAR ============ -->
<div id="sidebar">
<div class="sidebar-header">
<div class="logo">G</div>
<div>
<h1>Cortex Graph Explorer</h1>
<div class="subtitle">Neo4j Product Graph Visualizer</div>
</div>
</div>
<div class="sidebar-scroll">
<!-- Connection -->
<div id="connection-status">
<div class="dot"></div>
<span id="conn-text">Connecting…</span>
</div>
<div id="conn-error-detail" style="display:none;font-size:11px;color:var(--danger);background:rgba(255,107,107,0.08);border:1px solid rgba(255,107,107,0.15);border-radius:var(--radius-sm);padding:8px 10px;margin-bottom:12px;word-break:break-word"></div>
<!-- Connection Settings -->
<div class="panel">
<div class="panel-head collapsed" data-panel="conn-settings">Connection Settings <span class="arrow"></span></div>
<div class="panel-body collapsed" data-panel="conn-settings">
<label style="font-size:11px;color:var(--text-dim);display:block;margin-bottom:4px">Neo4j HTTP URL</label>
<input type="text" id="conn-uri" class="styled-select" style="width:100%;margin-bottom:8px" value="https://neo4j.develop.cortex.cloud.otto.de">
<label style="font-size:11px;color:var(--text-dim);display:block;margin-bottom:4px">Username</label>
<input type="text" id="conn-user" class="styled-select" style="width:100%;margin-bottom:8px" value="neo4j">
<label style="font-size:11px;color:var(--text-dim);display:block;margin-bottom:4px">Password</label>
<input type="password" id="conn-pass" class="styled-select" style="width:100%;margin-bottom:8px" value="" placeholder="(empty)">
<button class="btn btn-secondary btn-sm" onclick="reconnect()" style="width:100%">Reconnect</button>
<div style="font-size:10px;color:var(--text-muted);margin-top:6px">Uses Neo4j HTTP Transactional API</div>
</div>
</div>
<!-- Query Editor -->
<div class="panel">
<div class="panel-head" data-panel="query">Cypher Query <span class="arrow"></span></div>
<div class="panel-body" data-panel="query">
<textarea id="query-editor" spellcheck="false" placeholder="MATCH (n)-[r]->(m)&#10;RETURN n, r, m&#10;LIMIT 100"></textarea>
<div class="controls-row">
<button class="btn btn-primary" id="run-btn" onclick="runQuery()">▶ Run</button>
<button class="btn btn-secondary" onclick="runDemo()" title="Generate demo graph without Neo4j">✦ Demo</button>
<select class="styled-select" id="layout-select" title="Layout algorithm">
<option value="auto">Auto Layout</option>
</select>
</div>
<div class="controls-row" style="margin-top:4px">
<select class="styled-select" id="demo-size" title="Demo graph size">
<option value="100">Demo: 100 nodes</option>
<option value="300" selected>Demo: 300 nodes</option>
<option value="1000">Demo: 1,000 nodes</option>
<option value="3000">Demo: 3,000 nodes</option>
<option value="5000">Demo: 5,000 nodes</option>
</select>
</div>
</div>
</div>
<!-- Sample Queries -->
<div class="panel">
<div class="panel-head collapsed" data-panel="samples">Sample Queries <span class="arrow"></span></div>
<div class="panel-body collapsed" data-panel="samples" id="sample-queries-list"></div>
</div>
<!-- Schema -->
<div class="panel">
<div class="panel-head collapsed" data-panel="schema">Database Schema <span class="arrow"></span></div>
<div class="panel-body collapsed" data-panel="schema" id="schema-body">
<div style="color:var(--text-muted);font-size:12px">Loading…</div>
</div>
</div>
<!-- Visual Settings -->
<div class="panel">
<div class="panel-head" data-panel="visuals">Visual Settings <span class="arrow"></span></div>
<div class="panel-body" data-panel="visuals">
<div style="font-size:10px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.6px;margin-bottom:8px;font-weight:600">Edges</div>
<div class="slider-row">
<label>Curvature</label>
<input type="range" id="sl-curvature" min="0" max="1" step="0.05" value="0.35" oninput="updateVisual('curvature',this.value)">
<span class="slider-val" id="sv-curvature">0.35</span>
</div>
<div class="slider-row">
<label>Edge Opacity</label>
<input type="range" id="sl-edge-opacity" min="0" max="1" step="0.02" value="0" oninput="updateVisual('edgeOpacity',this.value)">
<span class="slider-val" id="sv-edge-opacity">auto</span>
</div>
<div class="slider-row">
<label>Edge Width</label>
<input type="range" id="sl-edge-width" min="0.3" max="5" step="0.1" value="1" oninput="updateVisual('edgeWidth',this.value)">
<span class="slider-val" id="sv-edge-width">1.0</span>
</div>
<div class="color-row">
<label>Edge Color</label>
<select class="styled-select" id="edge-color-scheme" onchange="updateVisual('edgeColorScheme',this.value)" style="flex:1">
<option value="byType" selected>By Rel Type</option>
<option value="gradient">Gradient (src→tgt)</option>
<option value="uniform">Uniform Color</option>
</select>
</div>
<div class="color-row" id="uniform-color-row" style="display:none">
<label>Color</label>
<input type="color" id="edge-uniform-color" value="#00d4ff" onchange="updateVisual('edgeColor',this.value)">
</div>
<div style="font-size:10px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.6px;margin:12px 0 8px;font-weight:600">Nodes &amp; Labels</div>
<div class="slider-row">
<label>Node Size</label>
<input type="range" id="sl-node-size" min="0.3" max="3" step="0.1" value="1" oninput="updateVisual('nodeSize',this.value)">
<span class="slider-val" id="sv-node-size">1.0</span>
</div>
<div class="slider-row">
<label>Label Zoom</label>
<input type="range" id="sl-label-zoom" min="0.3" max="5" step="0.1" value="1.2" oninput="updateVisual('labelZoom',this.value)">
<span class="slider-val" id="sv-label-zoom">1.2</span>
</div>
<div class="slider-row">
<label>Label Size</label>
<input type="range" id="sl-label-size" min="0.3" max="4" step="0.1" value="1" oninput="updateVisual('labelSize',this.value)">
<span class="slider-val" id="sv-label-size">1.0</span>
</div>
<div style="font-size:10px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.6px;margin:12px 0 8px;font-weight:600">Layout</div>
<div class="slider-row">
<label>Spacing</label>
<input type="range" id="sl-spacing" min="0.3" max="4" step="0.1" value="1" oninput="updateVisual('spacing',this.value)">
<span class="slider-val" id="sv-spacing">1.0</span>
</div>
<div class="slider-row">
<label>Iterations</label>
<input type="range" id="sl-iterations" min="50" max="1000" step="50" value="300" oninput="updateVisual('iterations',this.value)">
<span class="slider-val" id="sv-iterations">300</span>
</div>
<div style="font-size:10px;color:var(--text-muted);margin-top:6px">Spacing &amp; Iterations apply on next query run</div>
</div>
</div>
<!-- Node Search -->
<div class="panel">
<div class="panel-head" data-panel="search">Search Nodes <span class="arrow"></span></div>
<div class="panel-body" data-panel="search">
<input type="text" id="node-search" placeholder="Search by label…" oninput="searchNodes(this.value)">
<div id="search-results"></div>
</div>
</div>
<!-- Legend -->
<div class="panel">
<div class="panel-head" data-panel="legend">Legend <span class="arrow"></span></div>
<div class="panel-body" data-panel="legend" id="legend-body">
<div style="color:var(--text-muted);font-size:12px">Run a query to see labels</div>
</div>
</div>
</div>
<!-- Stats bar -->
<div id="stats-bar">
<span>Nodes: <span class="stat-val" id="stat-nodes">0</span></span>
<span>Edges: <span class="stat-val" id="stat-edges">0</span></span>
<span>Query: <span class="stat-val" id="stat-query-ms"></span></span>
<span>Layout: <span class="stat-val" id="stat-layout-ms"></span></span>
</div>
</div>
<!-- ============ GRAPH AREA ============ -->
<div id="graph-area">
<div class="view-toggle">
<button class="active" id="btn-graph-view" onclick="setView('graph')">Graph</button>
<button id="btn-table-view" onclick="setView('table')">Table</button>
</div>
<canvas id="graph-canvas"></canvas>
<div id="table-view"></div>
<div id="minimap"><canvas id="minimap-canvas"></canvas></div>
<div class="graph-controls">
<button onclick="zoomIn()" title="Zoom in">+</button>
<button onclick="zoomOut()" title="Zoom out"></button>
<button onclick="fitGraph()" title="Fit to screen"></button>
<button onclick="resetView()" title="Reset"></button>
</div>
<div id="tooltip">
<div class="tt-label"></div>
<div class="tt-labels"></div>
<div class="tt-props"></div>
</div>
<div id="loading-overlay">
<div class="spinner"></div>
<div id="loading-text">Executing query…</div>
</div>
<button id="sidebar-toggle" onclick="toggleSidebar()"></button>
</div>
<div id="error-toast"></div>
<!-- ============================================================
JAVASCRIPT — Graph Renderer & Application Logic
============================================================ -->
<script>
(function() {
'use strict';
/* =================================================================
STATE
================================================================= */
const state = {
nodes: [],
edges: [],
records: [],
keys: [],
labelColors: {},
// Spatial index
quadtree: null,
// Interaction
hoveredNode: null,
selectedNode: null,
highlightedNodes: new Map(), // nodeId -> hop distance
highlightedEdges: new Map(), // edge index -> hop distance
// View
currentView: 'graph',
// Edge index
edgeIndex: {}, // nodeId -> [edge indices]
// Visual settings (synced with sliders)
vis: {
curvature: 0.35,
edgeOpacity: 0, // 0 = auto
edgeWidth: 1.0,
edgeColorScheme: 'byType', // 'byType' | 'gradient' | 'uniform'
edgeColor: '#00d4ff',
nodeSize: 1.0,
labelZoom: 1.2,
labelSize: 1.0,
spacing: 1.0,
iterations: 300,
},
};
/* Canvas & rendering */
const canvas = document.getElementById('graph-canvas');
const ctx = canvas.getContext('2d');
const mmCanvas = document.getElementById('minimap-canvas');
const mmCtx = mmCanvas.getContext('2d');
let dpr = window.devicePixelRatio || 1;
let width, height;
let transform = d3.zoomIdentity;
let zoomBehavior;
/* =================================================================
INIT
================================================================= */
function init() {
resizeCanvas();
window.addEventListener('resize', resizeCanvas);
setupZoom();
setupHover();
loadLayouts();
loadSampleQueries();
loadSchema();
testConnection();
setupPanelToggles();
setupKeyboard();
draw();
}
function resizeCanvas() {
const rect = canvas.parentElement.getBoundingClientRect();
width = rect.width;
height = rect.height;
canvas.width = width * dpr;
canvas.height = height * dpr;
canvas.style.width = width + 'px';
canvas.style.height = height + 'px';
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
// Minimap
const mr = mmCanvas.parentElement.getBoundingClientRect();
mmCanvas.width = mr.width * dpr;
mmCanvas.height = mr.height * dpr;
mmCanvas.style.width = mr.width + 'px';
mmCanvas.style.height = mr.height + 'px';
draw();
}
/* =================================================================
ZOOM & PAN (D3)
================================================================= */
function setupZoom() {
zoomBehavior = d3.zoom()
.scaleExtent([0.01, 20])
.on('zoom', (event) => {
transform = event.transform;
draw();
});
d3.select(canvas).call(zoomBehavior);
// Disable double-click zoom
d3.select(canvas).on('dblclick.zoom', null);
}
window.zoomIn = () => d3.select(canvas).transition().duration(300).call(zoomBehavior.scaleBy, 1.4);
window.zoomOut = () => d3.select(canvas).transition().duration(300).call(zoomBehavior.scaleBy, 0.7);
window.fitGraph = function() {
if (!state.nodes.length) return;
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (const n of state.nodes) {
if (n.x < minX) minX = n.x;
if (n.x > maxX) maxX = n.x;
if (n.y < minY) minY = n.y;
if (n.y > maxY) maxY = n.y;
}
const gw = maxX - minX || 1;
const gh = maxY - minY || 1;
const pad = 60;
const scale = Math.min((width - 2*pad) / gw, (height - 2*pad) / gh, 5);
const cx = (minX + maxX) / 2;
const cy = (minY + maxY) / 2;
const tx = width/2 - cx * scale;
const ty = height/2 - cy * scale;
d3.select(canvas).transition().duration(600).ease(d3.easeCubicInOut)
.call(zoomBehavior.transform, d3.zoomIdentity.translate(tx, ty).scale(scale));
};
window.resetView = function() {
d3.select(canvas).transition().duration(500)
.call(zoomBehavior.transform, d3.zoomIdentity);
};
/* =================================================================
HOVER / CLICK
================================================================= */
function setupHover() {
canvas.addEventListener('mousemove', (e) => {
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
// Convert to graph coords
const gx = (mx - transform.x) / transform.k;
const gy = (my - transform.y) / transform.k;
const node = findNodeAt(gx, gy);
if (node !== state.hoveredNode) {
state.hoveredNode = node;
updateHighlight();
draw();
updateTooltip(e, node);
} else if (node) {
updateTooltip(e, node);
}
canvas.style.cursor = node ? 'pointer' : 'grab';
});
canvas.addEventListener('mouseleave', () => {
state.hoveredNode = null;
updateHighlight();
draw();
hideTooltip();
});
canvas.addEventListener('click', (e) => {
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
const gx = (mx - transform.x) / transform.k;
const gy = (my - transform.y) / transform.k;
const node = findNodeAt(gx, gy);
state.selectedNode = (state.selectedNode === node) ? null : node;
updateHighlight();
draw();
});
canvas.addEventListener('dblclick', (e) => {
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const my = e.clientY - rect.top;
const gx = (mx - transform.x) / transform.k;
const gy = (my - transform.y) / transform.k;
const node = findNodeAt(gx, gy);
if (node) {
// Zoom to node
const scale = 3;
const tx = width/2 - node.x * scale;
const ty = height/2 - node.y * scale;
d3.select(canvas).transition().duration(600)
.call(zoomBehavior.transform, d3.zoomIdentity.translate(tx, ty).scale(scale));
}
});
}
function findNodeAt(gx, gy) {
// Use quadtree for efficient hit testing
if (state.quadtree) {
const hitRadius = 20 / Math.max(transform.k, 0.1);
return state.quadtree.find(gx, gy, hitRadius) || null;
}
return null;
}
function updateHighlight() {
state.highlightedNodes.clear();
state.highlightedEdges.clear();
const focus = state.selectedNode || state.hoveredNode;
if (!focus) return;
// BFS: propagate highlight with increasing hop distance
const maxHops = 6;
state.highlightedNodes.set(focus.id, 0);
const queue = [focus.id];
let depth = 0;
while (queue.length > 0 && depth < maxHops) {
const nextQueue = [];
for (const nodeId of queue) {
const indices = state.edgeIndex[nodeId] || [];
for (const idx of indices) {
if (state.highlightedEdges.has(idx)) continue;
const e = state.edges[idx];
const neighbor = e.source === nodeId ? e.target : e.source;
state.highlightedEdges.set(idx, depth + 1);
if (!state.highlightedNodes.has(neighbor)) {
state.highlightedNodes.set(neighbor, depth + 1);
nextQueue.push(neighbor);
}
}
}
queue.length = 0;
queue.push(...nextQueue);
depth++;
}
}
/* =================================================================
TOOLTIP
================================================================= */
const tooltip = document.getElementById('tooltip');
function updateTooltip(event, node) {
if (!node) { hideTooltip(); return; }
const ttLabel = tooltip.querySelector('.tt-label');
const ttLabels = tooltip.querySelector('.tt-labels');
const ttProps = tooltip.querySelector('.tt-props');
ttLabel.textContent = node.label;
ttLabels.innerHTML = (node.labels || []).map(l =>
`<span class="tag" style="background:${hexToAlpha(state.labelColors[l]||'#888', 0.2)};color:${state.labelColors[l]||'#888'};border-color:${hexToAlpha(state.labelColors[l]||'#888', 0.3)}">${l}</span>`
).join(' ');
const props = node.properties || {};
const propKeys = Object.keys(props).slice(0, 12);
ttProps.innerHTML = propKeys.map(k => {
let v = props[k];
if (typeof v === 'object') v = JSON.stringify(v);
if (typeof v === 'string' && v.length > 60) v = v.substring(0, 57) + '…';
return `<div><span class="key">${k}:</span> <span class="val">${escHtml(String(v))}</span></div>`;
}).join('');
tooltip.classList.add('visible');
const x = event.clientX + 14;
const y = event.clientY - 10;
const graphRect = document.getElementById('graph-area').getBoundingClientRect();
tooltip.style.left = Math.min(x, graphRect.right - 380) + 'px';
tooltip.style.top = Math.min(y, graphRect.bottom - 200) + 'px';
}
function hideTooltip() {
tooltip.classList.remove('visible');
}
/* =================================================================
DRAW — Canvas Rendering
================================================================= */
function draw() {
ctx.save();
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, width, height);
// Background gradient
const bgGrad = ctx.createRadialGradient(width/2, height/2, 0, width/2, height/2, Math.max(width, height)*0.7);
bgGrad.addColorStop(0, '#0e1230');
bgGrad.addColorStop(1, '#060a1a');
ctx.fillStyle = bgGrad;
ctx.fillRect(0, 0, width, height);
// Subtle grid
drawGrid();
// Apply transform
ctx.translate(transform.x, transform.y);
ctx.scale(transform.k, transform.k);
const isHighlighting = state.highlightedNodes.size > 0;
const maxHopInHighlight = isHighlighting ? Math.max(...state.highlightedEdges.values(), 1) : 1;
const nodeCount = state.nodes.length;
const edgeCount = state.edges.length;
// Build node lookup for edge drawing
const nodeMap = state._nodeMap;
const V = state.vis;
// === EDGES ===
const autoAlpha = Math.max(0.03, Math.min(0.35, 30 / Math.sqrt(Math.max(edgeCount, 1))));
const baseAlpha = V.edgeOpacity > 0 ? V.edgeOpacity : autoAlpha;
const baseLw = Math.max(0.3, Math.min(1.5, 2 / Math.max(transform.k, 0.2))) * V.edgeWidth;
const curvature = V.curvature;
// Edge color palette by relationship type
const relColorCache = {};
function relColor(type) {
if (relColorCache[type]) return relColorCache[type];
let hash = 0;
for (let i = 0; i < type.length; i++) hash = ((hash << 5) - hash + type.charCodeAt(i)) | 0;
const h = ((hash % 360) + 360) % 360;
relColorCache[type] = `hsla(${h}, 50%, 60%, `;
return relColorCache[type];
}
function hexToRgba(hex, alpha) {
hex = hex.replace('#', '');
const r = parseInt(hex.substring(0,2), 16);
const g = parseInt(hex.substring(2,4), 16);
const b = parseInt(hex.substring(4,6), 16);
return `rgba(${r},${g},${b},${alpha})`;
}
// Detect multi-edges for curving
const edgePairCount = {};
const edgePairIndex = {};
for (let i = 0; i < edgeCount; i++) {
const e = state.edges[i];
const pairKey = e.source < e.target ? e.source + '|' + e.target : e.target + '|' + e.source;
edgePairCount[pairKey] = (edgePairCount[pairKey] || 0) + 1;
edgePairIndex[i] = edgePairCount[pairKey] - 1;
}
for (let i = 0; i < edgeCount; i++) {
const e = state.edges[i];
const src = nodeMap[e.source];
const tgt = nodeMap[e.target];
if (!src || !tgt) continue;
const sx = src.x, sy = src.y, tx = tgt.x, ty = tgt.y;
if (!lineVisible(sx, sy, tx, ty)) continue;
let alpha = baseAlpha;
let lw = baseLw;
if (isHighlighting) {
if (state.highlightedEdges.has(i)) {
const hop = state.highlightedEdges.get(i);
const fade = Math.pow(0.35, hop); // steeper exponential decay per hop
const maxAlpha = V.edgeOpacity > 0 ? V.edgeOpacity : 0.9;
alpha = maxAlpha * fade;
lw *= 1 + 1.5 * fade;
} else {
alpha *= 0.04;
}
}
// Determine curve offset for multi-edges + global curvature
const pairKey = e.source < e.target ? e.source + '|' + e.target : e.target + '|' + e.source;
const pairTotal = edgePairCount[pairKey] || 1;
const pairIdx = edgePairIndex[i] || 0;
const dx = tx - sx;
const dy = ty - sy;
const dist = Math.sqrt(dx * dx + dy * dy + 0.01);
const nx_ = -dy / dist; // perpendicular normal
const ny_ = dx / dist;
// Multi-edge spread + base curvature
let offset = 0;
if (pairTotal > 1) {
offset = (pairIdx - (pairTotal - 1) / 2) * 30 * Math.max(curvature, 0.3);
} else if (curvature > 0) {
// Single edge: use a deterministic offset based on edge index
const sign = (i % 2 === 0) ? 1 : -1;
offset = sign * curvature * dist * 0.15;
}
const mx = (sx + tx) / 2;
const my = (sy + ty) / 2;
const cpx = mx + nx_ * offset;
const cpy = my + ny_ * offset;
// Color based on scheme
let strokeStyle;
if (V.edgeColorScheme === 'uniform') {
strokeStyle = hexToRgba(V.edgeColor, alpha);
} else if (V.edgeColorScheme === 'gradient' && src.color && tgt.color) {
const grad = ctx.createLinearGradient(sx, sy, tx, ty);
grad.addColorStop(0, hexToAlpha(src.color, alpha));
grad.addColorStop(1, hexToAlpha(tgt.color, alpha));
strokeStyle = grad;
} else {
strokeStyle = relColor(e.type || 'RELATED') + alpha + ')';
}
ctx.beginPath();
ctx.strokeStyle = strokeStyle;
ctx.lineWidth = lw;
if (Math.abs(offset) > 0.5) {
ctx.moveTo(sx, sy);
ctx.quadraticCurveTo(cpx, cpy, tx, ty);
} else {
ctx.moveTo(sx, sy);
ctx.lineTo(tx, ty);
}
ctx.stroke();
// Draw relationship type label on highlighted edges when zoomed in (first 2 hops only)
if (isHighlighting && state.highlightedEdges.has(i) && state.highlightedEdges.get(i) <= 2 && transform.k > 1.0) {
// Label at the curve midpoint
const lx = (pairTotal > 1 || curvature > 0) ? (sx + 2*cpx + tx) / 4 : mx;
const ly = (pairTotal > 1 || curvature > 0) ? (sy + 2*cpy + ty) / 4 : my;
const fontSize = Math.max(6, Math.min(10, 8 / Math.max(transform.k * 0.3, 0.5)));
ctx.font = `500 ${fontSize}px Inter, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
const colorBase = relColor(e.type || 'RELATED');
ctx.fillStyle = colorBase + '0.9)';
ctx.shadowColor = 'rgba(0,0,0,0.9)';
ctx.shadowBlur = 3;
ctx.fillText(e.type || '', lx, ly - 6);
ctx.shadowBlur = 0;
}
}
// === NODES ===
const showLabels = transform.k > V.labelZoom && nodeCount < 2000;
const showLabelsAlways = transform.k > V.labelZoom * 2;
for (const node of state.nodes) {
// Frustum culling
const sx = node.x * transform.k + transform.x;
const sy = node.y * transform.k + transform.y;
if (sx < -100 || sx > width + 100 || sy < -100 || sy > height + 100) continue;
const r = Math.max(1.5, node.size * V.nodeSize * Math.min(transform.k * 0.5, 4));
const isHighlighted = state.highlightedNodes.has(node.id);
const isHovered = node === state.hoveredNode;
const isSelected = node === state.selectedNode;
let alpha = 1;
if (isHighlighting) {
if (isHighlighted) {
const hop = state.highlightedNodes.get(node.id);
alpha = Math.max(0.15, Math.pow(0.6, hop));
} else {
alpha = 0.06;
}
}
ctx.globalAlpha = alpha;
// Glow for hovered/selected/highlighted nodes
if ((isHovered || isSelected || (isHighlighted && isHighlighting)) && alpha > 0.5) {
ctx.save();
ctx.shadowColor = node.color;
ctx.shadowBlur = isHovered ? 25 : isSelected ? 20 : 12;
ctx.beginPath();
ctx.arc(node.x, node.y, r * 1.3, 0, Math.PI * 2);
ctx.fillStyle = node.color;
ctx.globalAlpha = alpha * 0.35;
ctx.fill();
ctx.restore();
ctx.globalAlpha = alpha;
}
// Node body — radial gradient
const grad = ctx.createRadialGradient(
node.x - r * 0.25, node.y - r * 0.25, 0,
node.x, node.y, r
);
grad.addColorStop(0, lightenColor(node.color, 50));
grad.addColorStop(1, node.color);
ctx.beginPath();
ctx.arc(node.x, node.y, r, 0, Math.PI * 2);
ctx.fillStyle = grad;
ctx.fill();
// Bright edge
ctx.strokeStyle = `rgba(255,255,255,${alpha * 0.25})`;
ctx.lineWidth = 0.5;
ctx.stroke();
// Label — in highlight mode only show for hop 0-1; sliders always apply
const hopDist = isHighlighted ? state.highlightedNodes.get(node.id) : Infinity;
const nearFocus = isHighlighting && isHighlighted && hopDist <= 1;
const normalShow = showLabelsAlways || (showLabels && r > 3);
const showLabel = alpha > 0.1 && (isHovered || isSelected || nearFocus || (!isHighlighting && normalShow) || (isHighlighting && normalShow && isHighlighted && hopDist <= 1));
if (showLabel) {
const fontSize = Math.max(4, Math.min(28, r * 1.2 * V.labelSize));
ctx.font = `500 ${fontSize}px Inter, sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillStyle = `rgba(255,255,255,${alpha * 0.9})`;
ctx.shadowColor = 'rgba(0,0,0,0.8)';
ctx.shadowBlur = 4;
const lines = wrapLabel(node.label, 20);
const lh = fontSize * 1.25;
for (let li = 0; li < lines.length; li++) {
ctx.fillText(lines[li], node.x, node.y + r + 3 + li * lh);
}
ctx.shadowBlur = 0;
}
ctx.globalAlpha = 1;
}
ctx.restore();
// Minimap
drawMinimap();
requestAnimationFrame(() => {}); // keep event loop alive
}
function drawGrid() {
const gridSize = 60;
const alpha = Math.min(0.06, 0.03 * transform.k);
if (alpha < 0.005) return;
ctx.strokeStyle = `rgba(100,140,200,${alpha})`;
ctx.lineWidth = 0.5;
const startX = (-transform.x / transform.k);
const startY = (-transform.y / transform.k);
const endX = startX + width / transform.k;
const endY = startY + height / transform.k;
const gs = gridSize / Math.max(transform.k, 0.05);
const gStartX = Math.floor(startX / gs) * gs;
const gStartY = Math.floor(startY / gs) * gs;
ctx.save();
ctx.translate(transform.x, transform.y);
ctx.scale(transform.k, transform.k);
for (let x = gStartX; x <= endX; x += gs) {
ctx.beginPath();
ctx.moveTo(x, startY);
ctx.lineTo(x, endY);
ctx.stroke();
}
for (let y = gStartY; y <= endY; y += gs) {
ctx.beginPath();
ctx.moveTo(startX, y);
ctx.lineTo(endX, y);
ctx.stroke();
}
ctx.restore();
}
function lineVisible(x1, y1, x2, y2) {
const sx1 = x1 * transform.k + transform.x;
const sy1 = y1 * transform.k + transform.y;
const sx2 = x2 * transform.k + transform.x;
const sy2 = y2 * transform.k + transform.y;
// Broad check
const margin = 50;
if (Math.max(sx1, sx2) < -margin) return false;
if (Math.min(sx1, sx2) > width + margin) return false;
if (Math.max(sy1, sy2) < -margin) return false;
if (Math.min(sy1, sy2) > height + margin) return false;
return true;
}
/* =================================================================
MINIMAP
================================================================= */
function drawMinimap() {
if (!state.nodes.length) return;
const mw = mmCanvas.width / dpr;
const mh = mmCanvas.height / dpr;
mmCtx.save();
mmCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
mmCtx.clearRect(0, 0, mw, mh);
mmCtx.fillStyle = 'rgba(6,10,26,0.9)';
mmCtx.fillRect(0, 0, mw, mh);
// Compute bounds
let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
for (const n of state.nodes) {
if (n.x < minX) minX = n.x;
if (n.x > maxX) maxX = n.x;
if (n.y < minY) minY = n.y;
if (n.y > maxY) maxY = n.y;
}
const pad = 20;
const gw = (maxX - minX) || 1;
const gh = (maxY - minY) || 1;
const scale = Math.min((mw - 2*pad) / gw, (mh - 2*pad) / gh);
const ox = (mw - gw * scale) / 2;
const oy = (mh - gh * scale) / 2;
// Draw nodes
const dotSize = Math.max(0.5, Math.min(2, 1));
for (const n of state.nodes) {
const x = (n.x - minX) * scale + ox;
const y = (n.y - minY) * scale + oy;
mmCtx.fillStyle = n.color;
mmCtx.globalAlpha = 0.7;
mmCtx.fillRect(x - dotSize/2, y - dotSize/2, dotSize, dotSize);
}
mmCtx.globalAlpha = 1;
// Viewport rectangle
const vx1 = (-transform.x / transform.k - minX) * scale + ox;
const vy1 = (-transform.y / transform.k - minY) * scale + oy;
const vw = (width / transform.k) * scale;
const vh = (height / transform.k) * scale;
mmCtx.strokeStyle = 'rgba(0,212,255,0.6)';
mmCtx.lineWidth = 1;
mmCtx.strokeRect(vx1, vy1, vw, vh);
mmCtx.restore();
}
/* =================================================================
DATA LOADING
================================================================= */
function loadGraphData(data) {
state.nodes = data.nodes || [];
state.edges = data.edges || [];
state.records = data.records || [];
state.keys = data.keys || [];
state.labelColors = data.label_colors || {};
state.hoveredNode = null;
state.selectedNode = null;
state.highlightedNodes = new Map();
state.highlightedEdges = new Map();
// Build node map
state._nodeMap = {};
for (const n of state.nodes) {
state._nodeMap[n.id] = n;
}
// Build edge index
state.edgeIndex = {};
for (let i = 0; i < state.edges.length; i++) {
const e = state.edges[i];
if (!state.edgeIndex[e.source]) state.edgeIndex[e.source] = [];
if (!state.edgeIndex[e.target]) state.edgeIndex[e.target] = [];
state.edgeIndex[e.source].push(i);
state.edgeIndex[e.target].push(i);
}
// Build quadtree for hit testing
state.quadtree = d3.quadtree()
.x(d => d.x)
.y(d => d.y)
.addAll(state.nodes);
// Update legend
updateLegend();
// Update stats
updateStats(data.stats);
// Update table
updateTable();
// Fit view
setTimeout(() => fitGraph(), 100);
draw();
}
/* =================================================================
LEGEND
================================================================= */
function updateLegend() {
const el = document.getElementById('legend-body');
const entries = Object.entries(state.labelColors);
if (!entries.length) {
el.innerHTML = '<div style="color:var(--text-muted);font-size:12px">No labels</div>';
return;
}
el.innerHTML = entries.map(([label, color]) => {
const count = state.nodes.filter(n => n.labels && n.labels.includes(label)).length;
return `<div class="legend-item">
<div class="legend-dot" style="background:${color};box-shadow:0 0 6px ${color}"></div>
<span>${escHtml(label)}</span>
<span style="color:var(--text-muted);font-size:11px;margin-left:auto">${count}</span>
</div>`;
}).join('');
}
function updateStats(stats) {
if (!stats) return;
document.getElementById('stat-nodes').textContent = stats.node_count?.toLocaleString() || '0';
document.getElementById('stat-edges').textContent = stats.edge_count?.toLocaleString() || '0';
document.getElementById('stat-query-ms').textContent = stats.query_time_ms ? stats.query_time_ms + 'ms' : '';
document.getElementById('stat-layout-ms').textContent = stats.layout_time_ms ? stats.layout_time_ms + 'ms' : '';
}
/* =================================================================
TABLE VIEW
================================================================= */
function updateTable() {
const el = document.getElementById('table-view');
if (!state.records.length || !state.keys.length) {
el.innerHTML = '<div style="color:var(--text-muted);padding:40px;text-align:center">No tabular data</div>';
return;
}
let html = '<table><thead><tr>';
for (const k of state.keys) {
html += `<th>${escHtml(k)}</th>`;
}
html += '</tr></thead><tbody>';
for (const row of state.records.slice(0, 500)) {
html += '<tr>';
for (const k of state.keys) {
let v = row[k];
if (v && typeof v === 'object') v = JSON.stringify(v);
html += `<td title="${escHtml(String(v ?? ''))}">${escHtml(String(v ?? ''))}</td>`;
}
html += '</tr>';
}
html += '</tbody></table>';
el.innerHTML = html;
}
/* =================================================================
VIEW TOGGLE
================================================================= */
window.setView = function(view) {
state.currentView = view;
document.getElementById('btn-graph-view').classList.toggle('active', view === 'graph');
document.getElementById('btn-table-view').classList.toggle('active', view === 'table');
document.getElementById('table-view').classList.toggle('active', view === 'table');
canvas.style.display = view === 'graph' ? 'block' : 'none';
document.getElementById('minimap').style.display = view === 'graph' ? 'block' : 'none';
if (view === 'graph') draw();
};
/* =================================================================
SEARCH
================================================================= */
window.searchNodes = function(query) {
const el = document.getElementById('search-results');
if (!query || query.length < 2) { el.innerHTML = ''; return; }
const q = query.toLowerCase();
const matches = state.nodes.filter(n => n.label.toLowerCase().includes(q)).slice(0, 20);
el.innerHTML = matches.map(n => `
<div class="sample-query" onclick="focusNode('${n.id}')" style="font-family:var(--mono);">
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${n.color};margin-right:6px"></span>
${escHtml(n.label)}
</div>
`).join('');
};
window.focusNode = function(nodeId) {
const node = state._nodeMap[nodeId];
if (!node) return;
state.selectedNode = node;
updateHighlight();
const scale = 3;
const tx = width/2 - node.x * scale;
const ty = height/2 - node.y * scale;
d3.select(canvas).transition().duration(600)
.call(zoomBehavior.transform, d3.zoomIdentity.translate(tx, ty).scale(scale));
};
/* =================================================================
API CALLS
================================================================= */
window.runQuery = async function() {
const editor = document.getElementById('query-editor');
const query = editor.value.trim();
if (!query) return;
const layout = document.getElementById('layout-select').value;
const spacing = state.vis.spacing;
const iterations = state.vis.iterations;
showLoading('Executing query…');
try {
const resp = await fetch('/api/query', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ query, layout, spacing, iterations }),
});
const data = await resp.json();
if (data.error) throw new Error(data.error);
loadGraphData(data);
hideLoading();
} catch (err) {
hideLoading();
showError(err.message);
}
};
window.runDemo = async function() {
const size = parseInt(document.getElementById('demo-size').value);
const layout = document.getElementById('layout-select').value;
const spacing = state.vis.spacing;
const iterations = state.vis.iterations;
showLoading(`Generating demo graph (${size} nodes)…`);
try {
const resp = await fetch('/api/demo', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ size, layout, spacing, iterations }),
});
const data = await resp.json();
if (data.error) throw new Error(data.error);
loadGraphData(data);
hideLoading();
} catch (err) {
hideLoading();
showError(err.message);
}
};
async function testConnection() {
const errDetail = document.getElementById('conn-error-detail');
try {
const resp = await fetch('/api/connection-test');
const data = await resp.json();
const el = document.getElementById('connection-status');
const txt = document.getElementById('conn-text');
if (data.status === 'connected') {
el.className = '';
el.classList.add('ok');
txt.textContent = 'Connected to Neo4j';
errDetail.style.display = 'none';
} else {
el.className = '';
el.classList.add('err');
txt.textContent = 'Connection failed';
errDetail.textContent = data.message || 'Unknown error';
errDetail.style.display = 'block';
}
} catch (e) {
const el = document.getElementById('connection-status');
el.className = '';
el.classList.add('err');
document.getElementById('conn-text').textContent = 'Connection error';
errDetail.textContent = e.message;
errDetail.style.display = 'block';
}
}
window.reconnect = async function() {
const uri = document.getElementById('conn-uri').value.trim();
const user = document.getElementById('conn-user').value.trim();
const pass = document.getElementById('conn-pass').value;
document.getElementById('conn-text').textContent = 'Reconnecting…';
document.getElementById('conn-error-detail').style.display = 'none';
try {
const resp = await fetch('/api/reconnect', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ uri, user, password: pass }),
});
const data = await resp.json();
if (data.status === 'connected') {
document.getElementById('connection-status').className = 'ok';
document.getElementById('conn-text').textContent = 'Connected to Neo4j';
loadSchema();
} else {
document.getElementById('connection-status').className = 'err';
document.getElementById('conn-text').textContent = 'Connection failed';
const errDetail = document.getElementById('conn-error-detail');
errDetail.textContent = data.message || 'Unknown error';
errDetail.style.display = 'block';
}
} catch (e) {
document.getElementById('connection-status').className = 'err';
document.getElementById('conn-text').textContent = 'Connection error';
}
};
async function loadSchema() {
try {
const resp = await fetch('/api/schema');
const data = await resp.json();
if (data.error) throw new Error(data.error);
const el = document.getElementById('schema-body');
let html = '';
if (data.labels?.length) {
html += '<div style="margin-bottom:8px;font-size:11px;color:var(--text-dim);font-weight:600">NODE LABELS</div>';
html += '<div class="tag-list">';
data.labels.forEach(l => html += `<span class="tag">${escHtml(l)}</span>`);
html += '</div>';
}
if (data.relationship_types?.length) {
html += '<div style="margin:12px 0 8px;font-size:11px;color:var(--text-dim);font-weight:600">RELATIONSHIPS</div>';
html += '<div class="tag-list">';
data.relationship_types.forEach(r => html += `<span class="tag rel">${escHtml(r)}</span>`);
html += '</div>';
}
el.innerHTML = html || '<div style="color:var(--text-muted);font-size:12px">Empty schema</div>';
} catch {
document.getElementById('schema-body').innerHTML = '<div style="color:var(--danger);font-size:12px">Failed to load schema</div>';
}
}
async function loadSampleQueries() {
try {
const resp = await fetch('/api/sample-queries');
const queries = await resp.json();
const el = document.getElementById('sample-queries-list');
el.innerHTML = queries.map(q => `
<div class="sample-query" onclick="loadSample(this)" data-query="${escAttr(q.query)}">
${escHtml(q.name)}
</div>
`).join('');
} catch {}
}
async function loadLayouts() {
try {
const resp = await fetch('/api/layouts');
const layouts = await resp.json();
const sel = document.getElementById('layout-select');
sel.innerHTML = '';
layouts.forEach(l => {
const opt = document.createElement('option');
opt.value = l.id;
opt.textContent = l.name;
opt.title = l.description;
sel.appendChild(opt);
});
} catch {}
}
window.loadSample = function(el) {
const query = el.getAttribute('data-query');
document.getElementById('query-editor').value = query;
};
/* =================================================================
PANEL TOGGLES
================================================================= */
function setupPanelToggles() {
document.querySelectorAll('.panel-head').forEach(head => {
head.addEventListener('click', () => {
head.classList.toggle('collapsed');
const panel = head.getAttribute('data-panel');
document.querySelector(`.panel-body[data-panel="${panel}"]`).classList.toggle('collapsed');
});
});
}
/* =================================================================
KEYBOARD SHORTCUTS
================================================================= */
function setupKeyboard() {
document.addEventListener('keydown', (e) => {
// Ctrl/Cmd + Enter to run query
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
e.preventDefault();
runQuery();
}
// Escape to deselect
if (e.key === 'Escape') {
state.selectedNode = null;
state.hoveredNode = null;
updateHighlight();
draw();
hideTooltip();
}
});
}
/* =================================================================
VISUAL SETTINGS
================================================================= */
window.updateVisual = function(key, value) {
const num = parseFloat(value);
state.vis[key] = isNaN(num) ? value : num;
// Update display label
const label = document.getElementById('sv-' + key.replace(/([A-Z])/g, '-$1').toLowerCase());
if (label) {
if (key === 'edgeOpacity' && parseFloat(value) === 0) {
label.textContent = 'auto';
} else {
label.textContent = isNaN(num) ? value : num.toFixed(key === 'iterations' ? 0 : (key === 'spacing' ? 1 : 2));
}
}
// Show/hide uniform color picker
if (key === 'edgeColorScheme') {
document.getElementById('uniform-color-row').style.display = value === 'uniform' ? 'flex' : 'none';
}
draw();
};
/* =================================================================
UI HELPERS
================================================================= */
function showLoading(text) {
document.getElementById('loading-text').textContent = text || 'Loading…';
document.getElementById('loading-overlay').classList.add('active');
document.getElementById('run-btn').disabled = true;
}
function hideLoading() {
document.getElementById('loading-overlay').classList.remove('active');
document.getElementById('run-btn').disabled = false;
}
let errorTimeout;
function showError(msg) {
const el = document.getElementById('error-toast');
el.textContent = msg;
el.classList.add('visible');
clearTimeout(errorTimeout);
errorTimeout = setTimeout(() => el.classList.remove('visible'), 6000);
}
window.toggleSidebar = function() {
document.getElementById('sidebar').classList.toggle('collapsed');
setTimeout(() => { resizeCanvas(); }, 300);
};
/* =================================================================
COLOR UTILITIES
================================================================= */
function wrapLabel(text, maxLen) {
if (!text || text.length <= maxLen) return [text || ''];
// Try to break at a space near maxLen
let breakIdx = text.lastIndexOf(' ', maxLen);
if (breakIdx < 4) breakIdx = maxLen; // no good space, hard break
const line1 = text.substring(0, breakIdx).trim();
let line2 = text.substring(breakIdx).trim();
if (line2.length > maxLen) line2 = line2.substring(0, maxLen - 1) + '\u2026';
return [line1, line2];
}
function lightenColor(hex, amount) {
hex = hex.replace('#', '');
let r = parseInt(hex.substring(0,2), 16);
let g = parseInt(hex.substring(2,4), 16);
let b = parseInt(hex.substring(4,6), 16);
r = Math.min(255, r + amount);
g = Math.min(255, g + amount);
b = Math.min(255, b + amount);
return `rgb(${r},${g},${b})`;
}
function hexToAlpha(hex, alpha) {
hex = hex.replace('#', '');
const r = parseInt(hex.substring(0,2), 16);
const g = parseInt(hex.substring(2,4), 16);
const b = parseInt(hex.substring(4,6), 16);
return `rgba(${r},${g},${b},${alpha})`;
}
function escHtml(s) {
const map = {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#039;'};
return s.replace(/[&<>"']/g, m => map[m]);
}
function escAttr(s) {
return s.replace(/"/g, '&quot;').replace(/'/g, '&#039;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
/* =================================================================
BOOT
================================================================= */
init();
})();
</script>
</body>
</html>