Star-Mapper/templates/index.html

2130 lines
70 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>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>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="http://localhost:7474">
<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>