2026-03-06 16:34:01 +00:00
|
|
|
|
<!DOCTYPE html>
|
|
|
|
|
|
<html lang="en">
|
2026-03-07 13:24:32 +00:00
|
|
|
|
|
2026-03-06 16:34:01 +00:00
|
|
|
|
<head>
|
2026-03-07 13:24:32 +00:00
|
|
|
|
<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>
|
|
|
|
|
|
/* =========================================================
|
2026-03-06 16:34:01 +00:00
|
|
|
|
CSS — Cortex Graph Explorer
|
|
|
|
|
|
Dark theme with glass-morphism, glow accents
|
|
|
|
|
|
========================================================= */
|
2026-03-07 13:24:32 +00:00
|
|
|
|
*,
|
|
|
|
|
|
*::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;
|
2026-03-07 15:17:52 +00:00
|
|
|
|
appearance: none;
|
2026-03-07 13:24:32 +00:00
|
|
|
|
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>
|
2026-03-06 16:34:01 +00:00
|
|
|
|
</head>
|
2026-03-07 13:24:32 +00:00
|
|
|
|
|
2026-03-06 16:34:01 +00:00
|
|
|
|
<body>
|
|
|
|
|
|
|
2026-03-07 13:24:32 +00:00
|
|
|
|
<!-- ============ 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>
|
2026-03-06 16:34:01 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-07 13:24:32 +00:00
|
|
|
|
<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">
|
2026-03-06 16:34:01 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-07 13:24:32 +00:00
|
|
|
|
<!-- Connection Settings -->
|
|
|
|
|
|
<div class="panel">
|
|
|
|
|
|
<div class="panel-head collapsed" data-panel="conn-settings">Connection Settings <span class="arrow">▼</span>
|
2026-03-06 16:34:01 +00:00
|
|
|
|
</div>
|
2026-03-07 13:24:32 +00:00
|
|
|
|
<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>
|
2026-03-06 16:34:01 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-07 13:24:32 +00:00
|
|
|
|
<!-- 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>
|
2026-03-06 16:34:01 +00:00
|
|
|
|
|
2026-03-07 13:24:32 +00:00
|
|
|
|
<!-- 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>
|
2026-03-06 16:34:01 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-07 13:24:32 +00:00
|
|
|
|
<!-- 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>
|
2026-03-06 16:34:01 +00:00
|
|
|
|
</div>
|
2026-03-07 13:24:32 +00:00
|
|
|
|
</div>
|
2026-03-06 16:34:01 +00:00
|
|
|
|
|
2026-03-07 13:24:32 +00:00
|
|
|
|
<!-- 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>
|
2026-03-07 15:17:52 +00:00
|
|
|
|
<div class="color-row">
|
|
|
|
|
|
<label>Edge Labels</label>
|
|
|
|
|
|
<select class="styled-select" id="edge-label-mode" onchange="updateVisual('edgeLabelMode',this.value)"
|
|
|
|
|
|
style="flex:1">
|
|
|
|
|
|
<option value="highlighted" selected>Highlighted Only</option>
|
|
|
|
|
|
<option value="all">All Edges</option>
|
|
|
|
|
|
<option value="off">Off</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="slider-row">
|
|
|
|
|
|
<label>Edge Label Zoom</label>
|
|
|
|
|
|
<input type="range" id="sl-edge-label-zoom" min="0.2" max="6" step="0.1" value="1.0"
|
|
|
|
|
|
oninput="updateVisual('edgeLabelZoom',this.value)">
|
|
|
|
|
|
<span class="slider-val" id="sv-edge-label-zoom">1.00</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="slider-row" id="edge-label-hop-row">
|
|
|
|
|
|
<label>Edge Label Hops</label>
|
|
|
|
|
|
<input type="range" id="sl-edge-label-max-hop" min="1" max="6" step="1" value="2"
|
|
|
|
|
|
oninput="updateVisual('edgeLabelMaxHop',this.value)">
|
|
|
|
|
|
<span class="slider-val" id="sv-edge-label-max-hop">2</span>
|
|
|
|
|
|
</div>
|
2026-03-07 13:24:32 +00:00
|
|
|
|
|
|
|
|
|
|
<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>
|
2026-03-07 15:17:52 +00:00
|
|
|
|
<div class="color-row">
|
|
|
|
|
|
<label>Node Label</label>
|
|
|
|
|
|
<select class="styled-select" id="node-label-source" onchange="updateVisual('nodeLabelSource',this.value)"
|
|
|
|
|
|
style="flex:1">
|
|
|
|
|
|
<option value="default" selected>Default</option>
|
|
|
|
|
|
<option value="id">Node ID</option>
|
|
|
|
|
|
<option value="primaryLabel">Primary Label</option>
|
|
|
|
|
|
<option value="property">Property Key</option>
|
|
|
|
|
|
</select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="color-row" id="node-label-property-row" style="display:none">
|
|
|
|
|
|
<label>Property</label>
|
|
|
|
|
|
<input type="text" id="node-label-property" class="styled-select" style="width:100%" placeholder="name"
|
|
|
|
|
|
oninput="updateVisual('nodeLabelProperty',this.value)">
|
|
|
|
|
|
</div>
|
2026-03-07 13:24:32 +00:00
|
|
|
|
<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>
|
2026-03-07 15:17:52 +00:00
|
|
|
|
<div
|
|
|
|
|
|
style="font-size:10px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.6px;margin:12px 0 8px;font-weight:600">
|
|
|
|
|
|
Node Colors</div>
|
|
|
|
|
|
<div class="controls-row" style="margin-top:0">
|
|
|
|
|
|
<button class="btn btn-secondary btn-sm" onclick="resetNodeColorOverrides()">Reset Colors</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div id="node-color-overrides"
|
|
|
|
|
|
style="max-height:180px;overflow:auto;margin-top:8px;padding-right:4px;color:var(--text-dim);font-size:12px">
|
|
|
|
|
|
<div style="color:var(--text-muted)">Run a query to edit label colors</div>
|
|
|
|
|
|
</div>
|
2026-03-07 13:24:32 +00:00
|
|
|
|
|
|
|
|
|
|
<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>
|
2026-03-06 16:34:01 +00:00
|
|
|
|
</div>
|
2026-03-07 13:24:32 +00:00
|
|
|
|
</div>
|
2026-03-06 16:34:01 +00:00
|
|
|
|
|
2026-03-07 13:24:32 +00:00
|
|
|
|
<!-- 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>
|
2026-03-06 16:34:01 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-07 13:24:32 +00:00
|
|
|
|
<!-- 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>
|
2026-03-06 16:34:01 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-07 13:24:32 +00:00
|
|
|
|
<!-- 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>
|
2026-03-06 16:34:01 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-07 13:24:32 +00:00
|
|
|
|
<!-- ============ 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>
|
2026-03-06 16:34:01 +00:00
|
|
|
|
|
2026-03-07 13:24:32 +00:00
|
|
|
|
<canvas id="graph-canvas"></canvas>
|
|
|
|
|
|
<div id="table-view"></div>
|
2026-03-06 16:34:01 +00:00
|
|
|
|
|
2026-03-07 13:24:32 +00:00
|
|
|
|
<div id="minimap"><canvas id="minimap-canvas"></canvas></div>
|
2026-03-06 16:34:01 +00:00
|
|
|
|
|
2026-03-07 13:24:32 +00:00
|
|
|
|
<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>
|
2026-03-06 16:34:01 +00:00
|
|
|
|
|
2026-03-07 13:24:32 +00:00
|
|
|
|
<div id="tooltip">
|
|
|
|
|
|
<div class="tt-label"></div>
|
|
|
|
|
|
<div class="tt-labels"></div>
|
|
|
|
|
|
<div class="tt-props"></div>
|
|
|
|
|
|
</div>
|
2026-03-06 16:34:01 +00:00
|
|
|
|
|
2026-03-07 13:24:32 +00:00
|
|
|
|
<div id="loading-overlay">
|
|
|
|
|
|
<div class="spinner"></div>
|
|
|
|
|
|
<div id="loading-text">Executing query…</div>
|
|
|
|
|
|
</div>
|
2026-03-06 16:34:01 +00:00
|
|
|
|
|
2026-03-07 13:24:32 +00:00
|
|
|
|
<button id="sidebar-toggle" onclick="toggleSidebar()">☰</button>
|
2026-03-06 16:34:01 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-07 13:24:32 +00:00
|
|
|
|
<div id="error-toast"></div>
|
2026-03-06 16:34:01 +00:00
|
|
|
|
|
2026-03-07 13:24:32 +00:00
|
|
|
|
<!-- ============================================================
|
2026-03-06 16:34:01 +00:00
|
|
|
|
JAVASCRIPT — Graph Renderer & Application Logic
|
|
|
|
|
|
============================================================ -->
|
2026-03-07 13:24:32 +00:00
|
|
|
|
<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',
|
2026-03-07 15:17:52 +00:00
|
|
|
|
edgeLabelMode: 'highlighted', // 'off' | 'highlighted' | 'all'
|
|
|
|
|
|
edgeLabelZoom: 1.0,
|
|
|
|
|
|
edgeLabelMaxHop: 2,
|
2026-03-07 13:24:32 +00:00
|
|
|
|
nodeSize: 1.0,
|
2026-03-07 15:17:52 +00:00
|
|
|
|
nodeLabelSource: 'default', // 'default' | 'id' | 'primaryLabel' | 'property'
|
|
|
|
|
|
nodeLabelProperty: '',
|
2026-03-07 13:24:32 +00:00
|
|
|
|
labelZoom: 1.2,
|
|
|
|
|
|
labelSize: 1.0,
|
|
|
|
|
|
spacing: 1.0,
|
|
|
|
|
|
iterations: 300,
|
|
|
|
|
|
},
|
2026-03-07 15:17:52 +00:00
|
|
|
|
nodeColorOverrides: {},
|
2026-03-07 13:24:32 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/* 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;
|
2026-03-06 16:34:01 +00:00
|
|
|
|
}
|
2026-03-07 13:24:32 +00:00
|
|
|
|
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));
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
2026-03-06 16:34:01 +00:00
|
|
|
|
}
|
2026-03-07 13:24:32 +00:00
|
|
|
|
|
|
|
|
|
|
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++;
|
|
|
|
|
|
}
|
2026-03-06 16:34:01 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-07 13:24:32 +00:00
|
|
|
|
/* =================================================================
|
|
|
|
|
|
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');
|
|
|
|
|
|
|
2026-03-07 15:17:52 +00:00
|
|
|
|
ttLabel.textContent = node.displayLabel || node.label || node.id;
|
|
|
|
|
|
ttLabels.innerHTML = (node.labels || []).map(l => {
|
|
|
|
|
|
const c = getLabelColor(l, state.labelColors[l] || '#888888');
|
|
|
|
|
|
return `<span class="tag" style="background:${hexToAlpha(c, 0.2)};color:${c};border-color:${hexToAlpha(c, 0.3)}">${l}</span>`;
|
|
|
|
|
|
}).join(' ');
|
2026-03-07 13:24:32 +00:00
|
|
|
|
|
|
|
|
|
|
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';
|
2026-03-06 16:34:01 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-07 13:24:32 +00:00
|
|
|
|
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);
|
2026-03-07 15:17:52 +00:00
|
|
|
|
} else if (V.edgeColorScheme === 'gradient' && src.renderColor && tgt.renderColor) {
|
2026-03-07 13:24:32 +00:00
|
|
|
|
const grad = ctx.createLinearGradient(sx, sy, tx, ty);
|
2026-03-07 15:17:52 +00:00
|
|
|
|
grad.addColorStop(0, hexToAlpha(src.renderColor, alpha));
|
|
|
|
|
|
grad.addColorStop(1, hexToAlpha(tgt.renderColor, alpha));
|
2026-03-07 13:24:32 +00:00
|
|
|
|
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)
|
2026-03-07 15:17:52 +00:00
|
|
|
|
const edgeLabelMode = V.edgeLabelMode;
|
|
|
|
|
|
const canDrawEdgeLabels = edgeLabelMode !== 'off' && transform.k >= V.edgeLabelZoom;
|
|
|
|
|
|
const highlightedEligible = isHighlighting
|
|
|
|
|
|
&& state.highlightedEdges.has(i)
|
|
|
|
|
|
&& state.highlightedEdges.get(i) <= V.edgeLabelMaxHop;
|
|
|
|
|
|
const shouldDrawEdgeLabel = canDrawEdgeLabels && (
|
|
|
|
|
|
edgeLabelMode === 'all'
|
|
|
|
|
|
|| (edgeLabelMode === 'highlighted' && highlightedEligible)
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
if (shouldDrawEdgeLabel) {
|
2026-03-07 13:24:32 +00:00
|
|
|
|
// 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();
|
2026-03-07 15:17:52 +00:00
|
|
|
|
ctx.shadowColor = node.renderColor;
|
2026-03-07 13:24:32 +00:00
|
|
|
|
ctx.shadowBlur = isHovered ? 25 : isSelected ? 20 : 12;
|
|
|
|
|
|
ctx.beginPath();
|
|
|
|
|
|
ctx.arc(node.x, node.y, r * 1.3, 0, Math.PI * 2);
|
2026-03-07 15:17:52 +00:00
|
|
|
|
ctx.fillStyle = node.renderColor;
|
2026-03-07 13:24:32 +00:00
|
|
|
|
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
|
|
|
|
|
|
);
|
2026-03-07 15:17:52 +00:00
|
|
|
|
grad.addColorStop(0, lightenColor(node.renderColor, 50));
|
|
|
|
|
|
grad.addColorStop(1, node.renderColor);
|
2026-03-07 13:24:32 +00:00
|
|
|
|
|
|
|
|
|
|
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;
|
2026-03-07 15:17:52 +00:00
|
|
|
|
const lines = wrapLabel(node.displayLabel || node.label || node.id, 20);
|
2026-03-07 13:24:32 +00:00
|
|
|
|
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();
|
2026-03-06 16:34:01 +00:00
|
|
|
|
}
|
2026-03-07 13:24:32 +00:00
|
|
|
|
|
|
|
|
|
|
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;
|
2026-03-07 15:17:52 +00:00
|
|
|
|
mmCtx.fillStyle = n.renderColor;
|
2026-03-07 13:24:32 +00:00
|
|
|
|
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);
|
|
|
|
|
|
|
2026-03-07 15:17:52 +00:00
|
|
|
|
renderNodeColorControls();
|
|
|
|
|
|
applyNodeStyling();
|
|
|
|
|
|
|
2026-03-07 13:24:32 +00:00
|
|
|
|
// 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]) => {
|
2026-03-07 15:17:52 +00:00
|
|
|
|
const effectiveColor = getLabelColor(label, color);
|
2026-03-07 13:24:32 +00:00
|
|
|
|
const count = state.nodes.filter(n => n.labels && n.labels.includes(label)).length;
|
|
|
|
|
|
return `<div class="legend-item">
|
2026-03-07 15:17:52 +00:00
|
|
|
|
<div class="legend-dot" style="background:${effectiveColor};box-shadow:0 0 6px ${effectiveColor}"></div>
|
2026-03-06 16:34:01 +00:00
|
|
|
|
<span>${escHtml(label)}</span>
|
|
|
|
|
|
<span style="color:var(--text-muted);font-size:11px;margin-left:auto">${count}</span>
|
|
|
|
|
|
</div>`;
|
2026-03-07 13:24:32 +00:00
|
|
|
|
}).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();
|
2026-03-07 15:17:52 +00:00
|
|
|
|
const matches = state.nodes.filter(n => (n.displayLabel || n.label || '').toLowerCase().includes(q)).slice(0, 20);
|
2026-03-07 13:24:32 +00:00
|
|
|
|
el.innerHTML = matches.map(n => `
|
2026-03-06 16:34:01 +00:00
|
|
|
|
<div class="sample-query" onclick="focusNode('${n.id}')" style="font-family:var(--mono);">
|
2026-03-07 15:17:52 +00:00
|
|
|
|
<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${n.renderColor};margin-right:6px"></span>
|
|
|
|
|
|
${escHtml(n.displayLabel || n.label || n.id)}
|
2026-03-06 16:34:01 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
`).join('');
|
2026-03-07 13:24:32 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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 => `
|
2026-03-06 16:34:01 +00:00
|
|
|
|
<div class="sample-query" onclick="loadSample(this)" data-query="${escAttr(q.query)}">
|
|
|
|
|
|
${escHtml(q.name)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`).join('');
|
2026-03-07 13:24:32 +00:00
|
|
|
|
} 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';
|
|
|
|
|
|
}
|
2026-03-07 15:17:52 +00:00
|
|
|
|
if (key === 'edgeLabelMode') {
|
|
|
|
|
|
document.getElementById('edge-label-hop-row').style.display = value === 'highlighted' ? 'flex' : 'none';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (key === 'nodeLabelSource') {
|
|
|
|
|
|
document.getElementById('node-label-property-row').style.display = value === 'property' ? 'flex' : 'none';
|
|
|
|
|
|
}
|
|
|
|
|
|
if (key === 'nodeLabelSource' || key === 'nodeLabelProperty') {
|
|
|
|
|
|
applyNodeStyling();
|
|
|
|
|
|
updateLegend();
|
|
|
|
|
|
}
|
2026-03-07 13:24:32 +00:00
|
|
|
|
draw();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-03-07 15:17:52 +00:00
|
|
|
|
window.setNodeLabelColor = function (label, color) {
|
|
|
|
|
|
state.nodeColorOverrides[label] = color;
|
|
|
|
|
|
applyNodeStyling();
|
|
|
|
|
|
updateLegend();
|
|
|
|
|
|
draw();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
window.resetNodeColorOverrides = function () {
|
|
|
|
|
|
state.nodeColorOverrides = {};
|
|
|
|
|
|
renderNodeColorControls();
|
|
|
|
|
|
applyNodeStyling();
|
|
|
|
|
|
updateLegend();
|
|
|
|
|
|
draw();
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
function getPrimaryNodeLabel(node) {
|
|
|
|
|
|
if (node.labels && node.labels.length) return node.labels[0];
|
|
|
|
|
|
return 'Unknown';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function getLabelColor(label, fallbackColor) {
|
|
|
|
|
|
return state.nodeColorOverrides[label] || fallbackColor || '#888888';
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function resolveNodeDisplayLabel(node) {
|
|
|
|
|
|
const source = state.vis.nodeLabelSource;
|
|
|
|
|
|
const fallback = node.label || node.id;
|
|
|
|
|
|
|
|
|
|
|
|
if (source === 'id') return String(node.id || fallback);
|
|
|
|
|
|
if (source === 'primaryLabel') return String(getPrimaryNodeLabel(node));
|
|
|
|
|
|
if (source === 'property') {
|
|
|
|
|
|
const key = (state.vis.nodeLabelProperty || '').trim();
|
|
|
|
|
|
if (!key) return String(fallback);
|
|
|
|
|
|
const value = node.properties ? node.properties[key] : undefined;
|
|
|
|
|
|
if (value === undefined || value === null || value === '') return String(fallback);
|
|
|
|
|
|
if (typeof value === 'object') return JSON.stringify(value);
|
|
|
|
|
|
return String(value);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return String(fallback);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function applyNodeStyling() {
|
|
|
|
|
|
for (const node of state.nodes) {
|
|
|
|
|
|
const primary = getPrimaryNodeLabel(node);
|
|
|
|
|
|
const baseColor = state.labelColors[primary] || node.color || '#888888';
|
|
|
|
|
|
node.renderColor = getLabelColor(primary, baseColor);
|
|
|
|
|
|
node.displayLabel = resolveNodeDisplayLabel(node);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
function renderNodeColorControls() {
|
|
|
|
|
|
const el = document.getElementById('node-color-overrides');
|
|
|
|
|
|
const labels = Object.keys(state.labelColors || {}).sort();
|
|
|
|
|
|
if (!labels.length) {
|
|
|
|
|
|
el.innerHTML = '<div style="color:var(--text-muted)">Run a query to edit label colors</div>';
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
el.innerHTML = labels.map(label => {
|
|
|
|
|
|
const color = getLabelColor(label, state.labelColors[label]);
|
|
|
|
|
|
return `
|
|
|
|
|
|
<div style="display:flex;align-items:center;gap:8px;margin-bottom:6px">
|
|
|
|
|
|
<span style="flex:1;min-width:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis" title="${escAttr(label)}">${escHtml(label)}</span>
|
|
|
|
|
|
<input type="color" value="${color}" data-label="${escAttr(label)}" onchange="setNodeLabelColor(this.dataset.label, this.value)">
|
|
|
|
|
|
</div>
|
|
|
|
|
|
`;
|
|
|
|
|
|
}).join('');
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-07 13:24:32 +00:00
|
|
|
|
/* =================================================================
|
|
|
|
|
|
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>
|
2026-03-06 16:34:01 +00:00
|
|
|
|
</body>
|
2026-03-07 13:24:32 +00:00
|
|
|
|
|
|
|
|
|
|
</html>
|