2130 lines
70 KiB
HTML
2130 lines
70 KiB
HTML
<!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) RETURN n, r, m 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 & 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 & 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 = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
||
return s.replace(/[&<>"']/g, m => map[m]);
|
||
}
|
||
|
||
function escAttr(s) {
|
||
return s.replace(/"/g, '"').replace(/'/g, ''').replace(/</g, '<').replace(/>/g, '>');
|
||
}
|
||
|
||
/* =================================================================
|
||
BOOT
|
||
================================================================= */
|
||
init();
|
||
|
||
})();
|
||
</script>
|
||
</body>
|
||
|
||
</html> |