<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Graph Explorer</title>
  <link rel="preconnect" href="https://fonts.googleapis.com">
  <link
    href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap"
    rel="stylesheet">
  <script src="https://d3js.org/d3.v7.min.js"></script>
  <style>
    /* =========================================================
   CSS — Cortex Graph Explorer
   Dark theme with glass-morphism, glow accents
   ========================================================= */
    *,
    *::before,
    *::after {
      box-sizing: border-box;
      margin: 0;
      padding: 0;
    }

    :root {
      --bg-deep: #060a1a;
      --bg-surface: rgba(12, 16, 38, 0.85);
      --bg-card: rgba(255, 255, 255, 0.04);
      --bg-card-hover: rgba(255, 255, 255, 0.07);
      --border: rgba(255, 255, 255, 0.08);
      --border-glow: rgba(0, 212, 255, 0.25);
      --text: #e2e8f0;
      --text-dim: #7a8ba8;
      --text-muted: #4a5568;
      --accent: #00d4ff;
      --accent-dim: rgba(0, 212, 255, 0.15);
      --danger: #ff6b6b;
      --success: #6bcb77;
      --warn: #ffd93d;
      --purple: #9b59b6;
      --radius: 12px;
      --radius-sm: 8px;
      --font: 'Inter', -apple-system, sans-serif;
      --mono: 'JetBrains Mono', 'Fira Code', monospace;
      --sidebar-w: 380px;
      --transition: 0.25s cubic-bezier(.4, 0, .2, 1);
    }

    html,
    body {
      height: 100%;
      overflow: hidden;
    }

    body {
      font-family: var(--font);
      background: var(--bg-deep);
      color: var(--text);
      display: flex;
      position: relative;
    }

    /* ---- Sidebar ---- */
    #sidebar {
      width: var(--sidebar-w);
      min-width: var(--sidebar-w);
      height: 100vh;
      display: flex;
      flex-direction: column;
      background: var(--bg-surface);
      backdrop-filter: blur(24px) saturate(1.4);
      border-right: 1px solid var(--border);
      z-index: 10;
      overflow: hidden;
      transition: transform var(--transition);
    }

    #sidebar.collapsed {
      transform: translateX(calc(-1 * var(--sidebar-w) + 36px));
    }

    .sidebar-header {
      padding: 20px 20px 14px;
      border-bottom: 1px solid var(--border);
      display: flex;
      align-items: center;
      gap: 12px;
    }

    .sidebar-header .logo {
      width: 32px;
      height: 32px;
      border-radius: 50%;
      background: linear-gradient(135deg, var(--accent), var(--purple));
      display: flex;
      align-items: center;
      justify-content: center;
      font-weight: 700;
      font-size: 15px;
      color: #fff;
      box-shadow: 0 0 20px rgba(0, 212, 255, 0.3);
    }

    .sidebar-header h1 {
      font-size: 16px;
      font-weight: 600;
      letter-spacing: -.3px;
    }

    .sidebar-header .subtitle {
      font-size: 11px;
      color: var(--text-dim);
      margin-top: 2px;
    }

    .sidebar-scroll {
      flex: 1;
      overflow-y: auto;
      overflow-x: hidden;
      padding: 16px;
      scrollbar-width: thin;
      scrollbar-color: var(--border) transparent;
    }

    .sidebar-scroll::-webkit-scrollbar {
      width: 5px;
    }

    .sidebar-scroll::-webkit-scrollbar-track {
      background: transparent;
    }

    .sidebar-scroll::-webkit-scrollbar-thumb {
      background: var(--border);
      border-radius: 3px;
    }

    /* ---- Panels / Cards ---- */
    .panel {
      background: var(--bg-card);
      border: 1px solid var(--border);
      border-radius: var(--radius);
      margin-bottom: 12px;
      overflow: hidden;
    }

    .panel-head {
      padding: 10px 14px;
      font-size: 11px;
      font-weight: 600;
      text-transform: uppercase;
      letter-spacing: .8px;
      color: var(--text-dim);
      cursor: pointer;
      display: flex;
      align-items: center;
      justify-content: space-between;
      user-select: none;
    }

    .panel-head .arrow {
      transition: transform var(--transition);
      font-size: 10px;
    }

    .panel-head.collapsed .arrow {
      transform: rotate(-90deg);
    }

    .panel-body {
      padding: 0 14px 14px;
    }

    .panel-body.collapsed {
      display: none;
    }

    /* ---- Connection indicator ---- */
    #connection-status {
      display: flex;
      align-items: center;
      gap: 8px;
      padding: 10px 14px;
      border-radius: var(--radius-sm);
      font-size: 12px;
      font-weight: 500;
      margin-bottom: 12px;
      background: var(--bg-card);
      border: 1px solid var(--border);
    }

    #connection-status .dot {
      width: 8px;
      height: 8px;
      border-radius: 50%;
      background: var(--text-muted);
      transition: background var(--transition), box-shadow var(--transition);
    }

    #connection-status.ok .dot {
      background: var(--success);
      box-shadow: 0 0 8px var(--success);
    }

    #connection-status.err .dot {
      background: var(--danger);
      box-shadow: 0 0 8px var(--danger);
    }

    /* ---- Query editor ---- */
    #query-editor {
      width: 100%;
      min-height: 120px;
      max-height: 300px;
      resize: vertical;
      background: rgba(0, 0, 0, 0.35);
      border: 1px solid var(--border);
      border-radius: var(--radius-sm);
      padding: 12px;
      color: var(--text);
      font-family: var(--mono);
      font-size: 12.5px;
      line-height: 1.6;
      outline: none;
      transition: border-color var(--transition), box-shadow var(--transition);
      tab-size: 2;
    }

    #query-editor:focus {
      border-color: var(--accent);
      box-shadow: 0 0 0 3px var(--accent-dim);
    }

    #query-editor::placeholder {
      color: var(--text-muted);
    }

    /* ---- Controls row ---- */
    .controls-row {
      display: flex;
      gap: 8px;
      margin-top: 10px;
      flex-wrap: wrap;
    }

    /* ---- Buttons ---- */
    .btn {
      display: inline-flex;
      align-items: center;
      justify-content: center;
      gap: 6px;
      padding: 8px 16px;
      border: none;
      border-radius: var(--radius-sm);
      font-family: var(--font);
      font-size: 12px;
      font-weight: 600;
      cursor: pointer;
      transition: all var(--transition);
      white-space: nowrap;
    }

    .btn-primary {
      background: linear-gradient(135deg, var(--accent), #0099cc);
      color: #000;
      box-shadow: 0 2px 12px rgba(0, 212, 255, 0.3);
    }

    .btn-primary:hover {
      box-shadow: 0 4px 20px rgba(0, 212, 255, 0.5);
      transform: translateY(-1px);
    }

    .btn-primary:active {
      transform: translateY(0);
    }

    .btn-primary:disabled {
      opacity: .5;
      cursor: not-allowed;
      transform: none;
      box-shadow: none;
    }

    .btn-secondary {
      background: var(--bg-card);
      color: var(--text);
      border: 1px solid var(--border);
    }

    .btn-secondary:hover {
      background: var(--bg-card-hover);
      border-color: var(--border-glow);
    }

    .btn-sm {
      padding: 5px 10px;
      font-size: 11px;
    }

    /* ---- Select ---- */
    .styled-select {
      background: rgba(0, 0, 0, 0.35);
      border: 1px solid var(--border);
      border-radius: var(--radius-sm);
      padding: 7px 10px;
      color: var(--text);
      font-family: var(--font);
      font-size: 12px;
      outline: none;
      cursor: pointer;
      flex: 1;
      min-width: 0;
    }

    .styled-select:focus {
      border-color: var(--accent);
    }

    .styled-select option {
      background: #141428;
      color: var(--text);
    }

    /* ---- Sample queries ---- */
    .sample-query {
      display: block;
      padding: 8px 10px;
      margin-bottom: 4px;
      font-size: 12px;
      color: var(--text-dim);
      border-radius: var(--radius-sm);
      cursor: pointer;
      transition: all var(--transition);
      border: 1px solid transparent;
    }

    .sample-query:hover {
      background: var(--accent-dim);
      color: var(--accent);
      border-color: var(--border-glow);
    }

    /* ---- Schema tags ---- */
    .tag-list {
      display: flex;
      flex-wrap: wrap;
      gap: 5px;
    }

    .tag {
      display: inline-block;
      padding: 3px 9px;
      font-size: 11px;
      border-radius: 20px;
      background: var(--accent-dim);
      color: var(--accent);
      border: 1px solid rgba(0, 212, 255, 0.15);
      font-family: var(--mono);
      cursor: default;
    }

    .tag.rel {
      background: rgba(255, 107, 107, 0.12);
      color: var(--danger);
      border-color: rgba(255, 107, 107, 0.15);
    }

    /* ---- Stats bar ---- */
    #stats-bar {
      padding: 10px 14px;
      font-size: 11px;
      color: var(--text-dim);
      border-top: 1px solid var(--border);
      display: flex;
      flex-wrap: wrap;
      gap: 12px;
    }

    #stats-bar .stat-val {
      color: var(--accent);
      font-weight: 600;
      font-family: var(--mono);
    }

    /* ---- Legend ---- */
    .legend-item {
      display: flex;
      align-items: center;
      gap: 8px;
      padding: 4px 0;
      font-size: 12px;
      color: var(--text-dim);
    }

    .legend-dot {
      width: 10px;
      height: 10px;
      border-radius: 50%;
      flex-shrink: 0;
    }

    /* ---- Graph area ---- */
    #graph-area {
      flex: 1;
      position: relative;
      overflow: hidden;
      background: radial-gradient(ellipse at 50% 50%, #0e1230 0%, #060a1a 70%);
    }

    #graph-canvas {
      position: absolute;
      top: 0;
      left: 0;
      width: 100%;
      height: 100%;
      cursor: grab;
    }

    #graph-canvas:active {
      cursor: grabbing;
    }

    /* ---- Overlay controls ---- */
    .graph-controls {
      position: absolute;
      bottom: 20px;
      right: 20px;
      display: flex;
      flex-direction: column;
      gap: 4px;
      z-index: 5;
    }

    .graph-controls button {
      width: 36px;
      height: 36px;
      border: 1px solid var(--border);
      background: var(--bg-surface);
      backdrop-filter: blur(12px);
      color: var(--text);
      border-radius: var(--radius-sm);
      font-size: 16px;
      cursor: pointer;
      display: flex;
      align-items: center;
      justify-content: center;
      transition: all var(--transition);
    }

    .graph-controls button:hover {
      background: var(--bg-card-hover);
      border-color: var(--accent);
      color: var(--accent);
    }

    /* ---- Tooltip ---- */
    #tooltip {
      position: absolute;
      pointer-events: none;
      background: var(--bg-surface);
      backdrop-filter: blur(16px);
      border: 1px solid var(--border-glow);
      border-radius: var(--radius);
      padding: 12px 16px;
      max-width: 360px;
      min-width: 180px;
      font-size: 12px;
      z-index: 20;
      opacity: 0;
      transform: translateY(4px);
      transition: opacity .15s, transform .15s;
      box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5), 0 0 20px rgba(0, 212, 255, 0.08);
    }

    #tooltip.visible {
      opacity: 1;
      transform: translateY(0);
    }

    #tooltip .tt-label {
      font-weight: 600;
      font-size: 14px;
      margin-bottom: 4px;
    }

    #tooltip .tt-labels {
      margin-bottom: 6px;
    }

    #tooltip .tt-props {
      color: var(--text-dim);
      line-height: 1.7;
    }

    #tooltip .tt-props .key {
      color: var(--accent);
      font-family: var(--mono);
      font-size: 11px;
    }

    #tooltip .tt-props .val {
      color: var(--text);
    }

    /* ---- Loading overlay ---- */
    #loading-overlay {
      position: absolute;
      inset: 0;
      background: rgba(6, 10, 26, 0.85);
      backdrop-filter: blur(8px);
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      z-index: 30;
      opacity: 0;
      pointer-events: none;
      transition: opacity .3s;
    }

    #loading-overlay.active {
      opacity: 1;
      pointer-events: all;
    }

    .spinner {
      width: 40px;
      height: 40px;
      border: 3px solid var(--border);
      border-top-color: var(--accent);
      border-radius: 50%;
      animation: spin .8s linear infinite;
      margin-bottom: 14px;
    }

    @keyframes spin {
      to {
        transform: rotate(360deg);
      }
    }

    #loading-text {
      font-size: 13px;
      color: var(--text-dim);
    }

    /* ---- Table view ---- */
    #table-view {
      display: none;
      position: absolute;
      inset: 0;
      background: var(--bg-deep);
      z-index: 8;
      overflow: auto;
      padding: 20px;
    }

    #table-view.active {
      display: block;
    }

    #table-view table {
      width: 100%;
      border-collapse: collapse;
      font-size: 12px;
    }

    #table-view th {
      padding: 10px 14px;
      text-align: left;
      font-weight: 600;
      color: var(--accent);
      border-bottom: 2px solid var(--border);
      position: sticky;
      top: 0;
      background: var(--bg-deep);
      font-family: var(--mono);
      font-size: 11px;
      text-transform: uppercase;
      letter-spacing: .5px;
    }

    #table-view td {
      padding: 8px 14px;
      border-bottom: 1px solid var(--border);
      color: var(--text-dim);
      max-width: 300px;
      overflow: hidden;
      text-overflow: ellipsis;
      white-space: nowrap;
      font-family: var(--mono);
      font-size: 11px;
    }

    #table-view tr:hover td {
      background: var(--bg-card);
      color: var(--text);
    }

    /* ---- View toggle ---- */
    .view-toggle {
      position: absolute;
      top: 16px;
      left: 50%;
      transform: translateX(-50%);
      display: flex;
      background: var(--bg-surface);
      backdrop-filter: blur(12px);
      border: 1px solid var(--border);
      border-radius: 20px;
      overflow: hidden;
      z-index: 9;
    }

    .view-toggle button {
      padding: 7px 18px;
      border: none;
      background: transparent;
      color: var(--text-dim);
      font-family: var(--font);
      font-size: 12px;
      font-weight: 500;
      cursor: pointer;
      transition: all var(--transition);
    }

    .view-toggle button.active {
      background: var(--accent-dim);
      color: var(--accent);
    }

    .view-toggle button:hover:not(.active) {
      color: var(--text);
    }

    /* ---- Search box ---- */
    #node-search {
      width: 100%;
      padding: 8px 12px;
      background: rgba(0, 0, 0, 0.3);
      border: 1px solid var(--border);
      border-radius: var(--radius-sm);
      color: var(--text);
      font-family: var(--font);
      font-size: 12px;
      outline: none;
      margin-bottom: 8px;
      transition: border-color var(--transition);
    }

    #node-search:focus {
      border-color: var(--accent);
    }

    #node-search::placeholder {
      color: var(--text-muted);
    }

    /* ---- Sidebar toggle ---- */
    #sidebar-toggle {
      position: absolute;
      top: 16px;
      left: 16px;
      z-index: 15;
      width: 32px;
      height: 32px;
      border: 1px solid var(--border);
      background: var(--bg-surface);
      backdrop-filter: blur(12px);
      color: var(--text);
      border-radius: var(--radius-sm);
      cursor: pointer;
      display: none;
      align-items: center;
      justify-content: center;
      font-size: 14px;
    }

    #sidebar.collapsed~#graph-area #sidebar-toggle {
      display: flex;
    }

    /* ---- Error toast ---- */
    #error-toast {
      position: fixed;
      bottom: 24px;
      left: 50%;
      transform: translateX(-50%) translateY(20px);
      background: rgba(255, 107, 107, 0.15);
      border: 1px solid var(--danger);
      color: var(--danger);
      padding: 12px 24px;
      border-radius: var(--radius);
      font-size: 13px;
      z-index: 100;
      opacity: 0;
      transition: opacity .3s, transform .3s;
      max-width: 600px;
      text-align: center;
      backdrop-filter: blur(12px);
    }

    #error-toast.visible {
      opacity: 1;
      transform: translateX(-50%) translateY(0);
    }

    /* ---- Minimap ---- */
    #minimap {
      position: absolute;
      bottom: 20px;
      left: 20px;
      width: 160px;
      height: 120px;
      background: rgba(6, 10, 26, 0.7);
      border: 1px solid var(--border);
      border-radius: var(--radius-sm);
      z-index: 5;
      overflow: hidden;
    }

    #minimap-canvas {
      width: 100%;
      height: 100%;
    }

    /* ---- Range sliders ---- */
    .slider-row {
      display: flex;
      align-items: center;
      gap: 10px;
      margin-bottom: 10px;
    }

    .slider-row label {
      font-size: 11px;
      color: var(--text-dim);
      min-width: 90px;
      white-space: nowrap;
    }

    .slider-row input[type="range"] {
      flex: 1;
      -webkit-appearance: none;
      appearance: none;
      height: 4px;
      background: var(--border);
      border-radius: 2px;
      outline: none;
      cursor: pointer;
    }

    .slider-row input[type="range"]::-webkit-slider-thumb {
      -webkit-appearance: none;
      width: 14px;
      height: 14px;
      border-radius: 50%;
      background: var(--accent);
      box-shadow: 0 0 8px rgba(0, 212, 255, 0.4);
      cursor: pointer;
      transition: box-shadow .2s;
    }

    .slider-row input[type="range"]::-webkit-slider-thumb:hover {
      box-shadow: 0 0 14px rgba(0, 212, 255, 0.7);
    }

    .slider-row input[type="range"]::-moz-range-thumb {
      width: 14px;
      height: 14px;
      border-radius: 50%;
      background: var(--accent);
      border: none;
      box-shadow: 0 0 8px rgba(0, 212, 255, 0.4);
      cursor: pointer;
    }

    .slider-row .slider-val {
      font-family: var(--mono);
      font-size: 11px;
      color: var(--accent);
      min-width: 32px;
      text-align: right;
    }

    .color-row {
      display: flex;
      align-items: center;
      gap: 10px;
      margin-bottom: 10px;
    }

    .color-row label {
      font-size: 11px;
      color: var(--text-dim);
      min-width: 90px;
    }

    .color-row input[type="color"] {
      -webkit-appearance: none;
      border: 1px solid var(--border);
      border-radius: 6px;
      width: 32px;
      height: 24px;
      padding: 0;
      cursor: pointer;
      background: transparent;
    }

    .color-row input[type="color"]::-webkit-color-swatch-wrapper {
      padding: 2px;
    }

    .color-row input[type="color"]::-webkit-color-swatch {
      border: none;
      border-radius: 4px;
    }

    .color-row select {
      flex: 1;
    }

    /* ---- Responsive ---- */
    @media (max-width: 900px) {
      :root {
        --sidebar-w: 320px;
      }
    }
  </style>
</head>

<body>

  <!-- ============ SIDEBAR ============ -->
  <div id="sidebar">
    <div class="sidebar-header">
      <div class="logo">G</div>
      <div>
        <h1>Graph Explorer</h1>
        <div class="subtitle">Neo4j Product Graph Visualizer</div>
      </div>
    </div>

    <div class="sidebar-scroll">
      <!-- Connection -->
      <div id="connection-status">
        <div class="dot"></div>
        <span id="conn-text">Connecting…</span>
      </div>
      <div id="conn-error-detail"
        style="display:none;font-size:11px;color:var(--danger);background:rgba(255,107,107,0.08);border:1px solid rgba(255,107,107,0.15);border-radius:var(--radius-sm);padding:8px 10px;margin-bottom:12px;word-break:break-word">
      </div>

      <!-- Connection Settings -->
      <div class="panel">
        <div class="panel-head collapsed" data-panel="conn-settings">Connection Settings <span class="arrow">▼</span>
        </div>
        <div class="panel-body collapsed" data-panel="conn-settings">
          <label style="font-size:11px;color:var(--text-dim);display:block;margin-bottom:4px">Neo4j HTTP URL</label>
          <input type="text" id="conn-uri" class="styled-select" style="width:100%;margin-bottom:8px"
            value="http://localhost:7474">
          <label style="font-size:11px;color:var(--text-dim);display:block;margin-bottom:4px">Username</label>
          <input type="text" id="conn-user" class="styled-select" style="width:100%;margin-bottom:8px" value="neo4j">
          <label style="font-size:11px;color:var(--text-dim);display:block;margin-bottom:4px">Password</label>
          <input type="password" id="conn-pass" class="styled-select" style="width:100%;margin-bottom:8px" value=""
            placeholder="(empty)">
          <button class="btn btn-secondary btn-sm" onclick="reconnect()" style="width:100%">Reconnect</button>
          <div style="font-size:10px;color:var(--text-muted);margin-top:6px">Uses Neo4j HTTP Transactional API</div>
        </div>
      </div>

      <!-- Query Editor -->
      <div class="panel">
        <div class="panel-head" data-panel="query">Cypher Query <span class="arrow">▼</span></div>
        <div class="panel-body" data-panel="query">
          <textarea id="query-editor" spellcheck="false"
            placeholder="MATCH (n)-[r]->(m)&#10;RETURN n, r, m&#10;LIMIT 100"></textarea>
          <div class="controls-row">
            <button class="btn btn-primary" id="run-btn" onclick="runQuery()">▶ Run</button>
            <button class="btn btn-secondary" onclick="runDemo()" title="Generate demo graph without Neo4j">✦
              Demo</button>
            <select class="styled-select" id="layout-select" title="Layout algorithm">
              <option value="auto">Auto Layout</option>
            </select>
          </div>
          <div class="controls-row" style="margin-top:4px">
            <select class="styled-select" id="demo-size" title="Demo graph size">
              <option value="100">Demo: 100 nodes</option>
              <option value="300" selected>Demo: 300 nodes</option>
              <option value="1000">Demo: 1,000 nodes</option>
              <option value="3000">Demo: 3,000 nodes</option>
              <option value="5000">Demo: 5,000 nodes</option>
            </select>
          </div>
        </div>
      </div>

      <!-- Sample Queries -->
      <div class="panel">
        <div class="panel-head collapsed" data-panel="samples">Sample Queries <span class="arrow">▼</span></div>
        <div class="panel-body collapsed" data-panel="samples" id="sample-queries-list"></div>
      </div>

      <!-- Schema -->
      <div class="panel">
        <div class="panel-head collapsed" data-panel="schema">Database Schema <span class="arrow">▼</span></div>
        <div class="panel-body collapsed" data-panel="schema" id="schema-body">
          <div style="color:var(--text-muted);font-size:12px">Loading…</div>
        </div>
      </div>

      <!-- Visual Settings -->
      <div class="panel">
        <div class="panel-head" data-panel="visuals">Visual Settings <span class="arrow">▼</span></div>
        <div class="panel-body" data-panel="visuals">
          <div
            style="font-size:10px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.6px;margin-bottom:8px;font-weight:600">
            Edges</div>
          <div class="slider-row">
            <label>Curvature</label>
            <input type="range" id="sl-curvature" min="0" max="1" step="0.05" value="0.35"
              oninput="updateVisual('curvature',this.value)">
            <span class="slider-val" id="sv-curvature">0.35</span>
          </div>
          <div class="slider-row">
            <label>Edge Opacity</label>
            <input type="range" id="sl-edge-opacity" min="0" max="1" step="0.02" value="0"
              oninput="updateVisual('edgeOpacity',this.value)">
            <span class="slider-val" id="sv-edge-opacity">auto</span>
          </div>
          <div class="slider-row">
            <label>Edge Width</label>
            <input type="range" id="sl-edge-width" min="0.3" max="5" step="0.1" value="1"
              oninput="updateVisual('edgeWidth',this.value)">
            <span class="slider-val" id="sv-edge-width">1.0</span>
          </div>
          <div class="color-row">
            <label>Edge Color</label>
            <select class="styled-select" id="edge-color-scheme" onchange="updateVisual('edgeColorScheme',this.value)"
              style="flex:1">
              <option value="byType" selected>By Rel Type</option>
              <option value="gradient">Gradient (src→tgt)</option>
              <option value="uniform">Uniform Color</option>
            </select>
          </div>
          <div class="color-row" id="uniform-color-row" style="display:none">
            <label>Color</label>
            <input type="color" id="edge-uniform-color" value="#00d4ff" onchange="updateVisual('edgeColor',this.value)">
          </div>

          <div
            style="font-size:10px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.6px;margin:12px 0 8px;font-weight:600">
            Nodes &amp; Labels</div>
          <div class="slider-row">
            <label>Node Size</label>
            <input type="range" id="sl-node-size" min="0.3" max="3" step="0.1" value="1"
              oninput="updateVisual('nodeSize',this.value)">
            <span class="slider-val" id="sv-node-size">1.0</span>
          </div>
          <div class="slider-row">
            <label>Label Zoom</label>
            <input type="range" id="sl-label-zoom" min="0.3" max="5" step="0.1" value="1.2"
              oninput="updateVisual('labelZoom',this.value)">
            <span class="slider-val" id="sv-label-zoom">1.2</span>
          </div>
          <div class="slider-row">
            <label>Label Size</label>
            <input type="range" id="sl-label-size" min="0.3" max="4" step="0.1" value="1"
              oninput="updateVisual('labelSize',this.value)">
            <span class="slider-val" id="sv-label-size">1.0</span>
          </div>

          <div
            style="font-size:10px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.6px;margin:12px 0 8px;font-weight:600">
            Layout</div>
          <div class="slider-row">
            <label>Spacing</label>
            <input type="range" id="sl-spacing" min="0.3" max="4" step="0.1" value="1"
              oninput="updateVisual('spacing',this.value)">
            <span class="slider-val" id="sv-spacing">1.0</span>
          </div>
          <div class="slider-row">
            <label>Iterations</label>
            <input type="range" id="sl-iterations" min="50" max="1000" step="50" value="300"
              oninput="updateVisual('iterations',this.value)">
            <span class="slider-val" id="sv-iterations">300</span>
          </div>
          <div style="font-size:10px;color:var(--text-muted);margin-top:6px">Spacing &amp; Iterations apply on next
            query run</div>
        </div>
      </div>

      <!-- Node Search -->
      <div class="panel">
        <div class="panel-head" data-panel="search">Search Nodes <span class="arrow">▼</span></div>
        <div class="panel-body" data-panel="search">
          <input type="text" id="node-search" placeholder="Search by label…" oninput="searchNodes(this.value)">
          <div id="search-results"></div>
        </div>
      </div>

      <!-- Legend -->
      <div class="panel">
        <div class="panel-head" data-panel="legend">Legend <span class="arrow">▼</span></div>
        <div class="panel-body" data-panel="legend" id="legend-body">
          <div style="color:var(--text-muted);font-size:12px">Run a query to see labels</div>
        </div>
      </div>
    </div>

    <!-- Stats bar -->
    <div id="stats-bar">
      <span>Nodes: <span class="stat-val" id="stat-nodes">0</span></span>
      <span>Edges: <span class="stat-val" id="stat-edges">0</span></span>
      <span>Query: <span class="stat-val" id="stat-query-ms">–</span></span>
      <span>Layout: <span class="stat-val" id="stat-layout-ms">–</span></span>
    </div>
  </div>

  <!-- ============ GRAPH AREA ============ -->
  <div id="graph-area">
    <div class="view-toggle">
      <button class="active" id="btn-graph-view" onclick="setView('graph')">Graph</button>
      <button id="btn-table-view" onclick="setView('table')">Table</button>
    </div>

    <canvas id="graph-canvas"></canvas>
    <div id="table-view"></div>

    <div id="minimap"><canvas id="minimap-canvas"></canvas></div>

    <div class="graph-controls">
      <button onclick="zoomIn()" title="Zoom in">+</button>
      <button onclick="zoomOut()" title="Zoom out">−</button>
      <button onclick="fitGraph()" title="Fit to screen">⊡</button>
      <button onclick="resetView()" title="Reset">↺</button>
    </div>

    <div id="tooltip">
      <div class="tt-label"></div>
      <div class="tt-labels"></div>
      <div class="tt-props"></div>
    </div>

    <div id="loading-overlay">
      <div class="spinner"></div>
      <div id="loading-text">Executing query…</div>
    </div>

    <button id="sidebar-toggle" onclick="toggleSidebar()">☰</button>
  </div>

  <div id="error-toast"></div>

  <!-- ============================================================
     JAVASCRIPT — Graph Renderer & Application Logic
     ============================================================ -->
  <script>
    (function () {
      'use strict';

      /* =================================================================
         STATE
         ================================================================= */
      const state = {
        nodes: [],
        edges: [],
        records: [],
        keys: [],
        labelColors: {},
        // Spatial index
        quadtree: null,
        // Interaction
        hoveredNode: null,
        selectedNode: null,
        highlightedNodes: new Map(),  // nodeId -> hop distance
        highlightedEdges: new Map(),  // edge index -> hop distance
        // View
        currentView: 'graph',
        // Edge index
        edgeIndex: {},  // nodeId -> [edge indices]
        // Visual settings (synced with sliders)
        vis: {
          curvature: 0.35,
          edgeOpacity: 0,      // 0 = auto
          edgeWidth: 1.0,
          edgeColorScheme: 'byType',  // 'byType' | 'gradient' | 'uniform'
          edgeColor: '#00d4ff',
          nodeSize: 1.0,
          labelZoom: 1.2,
          labelSize: 1.0,
          spacing: 1.0,
          iterations: 300,
        },
      };

      /* Canvas & rendering */
      const canvas = document.getElementById('graph-canvas');
      const ctx = canvas.getContext('2d');
      const mmCanvas = document.getElementById('minimap-canvas');
      const mmCtx = mmCanvas.getContext('2d');

      let dpr = window.devicePixelRatio || 1;
      let width, height;
      let transform = d3.zoomIdentity;
      let zoomBehavior;

      /* =================================================================
         INIT
         ================================================================= */
      function init() {
        resizeCanvas();
        window.addEventListener('resize', resizeCanvas);
        setupZoom();
        setupHover();
        loadLayouts();
        loadSampleQueries();
        loadSchema();
        testConnection();
        setupPanelToggles();
        setupKeyboard();
        draw();
      }

      function resizeCanvas() {
        const rect = canvas.parentElement.getBoundingClientRect();
        width = rect.width;
        height = rect.height;
        canvas.width = width * dpr;
        canvas.height = height * dpr;
        canvas.style.width = width + 'px';
        canvas.style.height = height + 'px';
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);

        // Minimap
        const mr = mmCanvas.parentElement.getBoundingClientRect();
        mmCanvas.width = mr.width * dpr;
        mmCanvas.height = mr.height * dpr;
        mmCanvas.style.width = mr.width + 'px';
        mmCanvas.style.height = mr.height + 'px';

        draw();
      }

      /* =================================================================
         ZOOM & PAN (D3)
         ================================================================= */
      function setupZoom() {
        zoomBehavior = d3.zoom()
          .scaleExtent([0.01, 20])
          .on('zoom', (event) => {
            transform = event.transform;
            draw();
          });
        d3.select(canvas).call(zoomBehavior);
        // Disable double-click zoom
        d3.select(canvas).on('dblclick.zoom', null);
      }

      window.zoomIn = () => d3.select(canvas).transition().duration(300).call(zoomBehavior.scaleBy, 1.4);
      window.zoomOut = () => d3.select(canvas).transition().duration(300).call(zoomBehavior.scaleBy, 0.7);

      window.fitGraph = function () {
        if (!state.nodes.length) return;
        let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
        for (const n of state.nodes) {
          if (n.x < minX) minX = n.x;
          if (n.x > maxX) maxX = n.x;
          if (n.y < minY) minY = n.y;
          if (n.y > maxY) maxY = n.y;
        }
        const gw = maxX - minX || 1;
        const gh = maxY - minY || 1;
        const pad = 60;
        const scale = Math.min((width - 2 * pad) / gw, (height - 2 * pad) / gh, 5);
        const cx = (minX + maxX) / 2;
        const cy = (minY + maxY) / 2;
        const tx = width / 2 - cx * scale;
        const ty = height / 2 - cy * scale;
        d3.select(canvas).transition().duration(600).ease(d3.easeCubicInOut)
          .call(zoomBehavior.transform, d3.zoomIdentity.translate(tx, ty).scale(scale));
      };

      window.resetView = function () {
        d3.select(canvas).transition().duration(500)
          .call(zoomBehavior.transform, d3.zoomIdentity);
      };

      /* =================================================================
         HOVER / CLICK
         ================================================================= */
      function setupHover() {
        canvas.addEventListener('mousemove', (e) => {
          const rect = canvas.getBoundingClientRect();
          const mx = e.clientX - rect.left;
          const my = e.clientY - rect.top;
          // Convert to graph coords
          const gx = (mx - transform.x) / transform.k;
          const gy = (my - transform.y) / transform.k;
          const node = findNodeAt(gx, gy);
          if (node !== state.hoveredNode) {
            state.hoveredNode = node;
            updateHighlight();
            draw();
            updateTooltip(e, node);
          } else if (node) {
            updateTooltip(e, node);
          }
          canvas.style.cursor = node ? 'pointer' : 'grab';
        });

        canvas.addEventListener('mouseleave', () => {
          state.hoveredNode = null;
          updateHighlight();
          draw();
          hideTooltip();
        });

        canvas.addEventListener('click', (e) => {
          const rect = canvas.getBoundingClientRect();
          const mx = e.clientX - rect.left;
          const my = e.clientY - rect.top;
          const gx = (mx - transform.x) / transform.k;
          const gy = (my - transform.y) / transform.k;
          const node = findNodeAt(gx, gy);
          state.selectedNode = (state.selectedNode === node) ? null : node;
          updateHighlight();
          draw();
        });

        canvas.addEventListener('dblclick', (e) => {
          const rect = canvas.getBoundingClientRect();
          const mx = e.clientX - rect.left;
          const my = e.clientY - rect.top;
          const gx = (mx - transform.x) / transform.k;
          const gy = (my - transform.y) / transform.k;
          const node = findNodeAt(gx, gy);
          if (node) {
            // Zoom to node
            const scale = 3;
            const tx = width / 2 - node.x * scale;
            const ty = height / 2 - node.y * scale;
            d3.select(canvas).transition().duration(600)
              .call(zoomBehavior.transform, d3.zoomIdentity.translate(tx, ty).scale(scale));
          }
        });
      }

      function findNodeAt(gx, gy) {
        // Use quadtree for efficient hit testing
        if (state.quadtree) {
          const hitRadius = 20 / Math.max(transform.k, 0.1);
          return state.quadtree.find(gx, gy, hitRadius) || null;
        }
        return null;
      }

      function updateHighlight() {
        state.highlightedNodes.clear();
        state.highlightedEdges.clear();
        const focus = state.selectedNode || state.hoveredNode;
        if (!focus) return;

        // BFS: propagate highlight with increasing hop distance
        const maxHops = 6;
        state.highlightedNodes.set(focus.id, 0);
        const queue = [focus.id];
        let depth = 0;

        while (queue.length > 0 && depth < maxHops) {
          const nextQueue = [];
          for (const nodeId of queue) {
            const indices = state.edgeIndex[nodeId] || [];
            for (const idx of indices) {
              if (state.highlightedEdges.has(idx)) continue;
              const e = state.edges[idx];
              const neighbor = e.source === nodeId ? e.target : e.source;
              state.highlightedEdges.set(idx, depth + 1);
              if (!state.highlightedNodes.has(neighbor)) {
                state.highlightedNodes.set(neighbor, depth + 1);
                nextQueue.push(neighbor);
              }
            }
          }
          queue.length = 0;
          queue.push(...nextQueue);
          depth++;
        }
      }

      /* =================================================================
         TOOLTIP
         ================================================================= */
      const tooltip = document.getElementById('tooltip');

      function updateTooltip(event, node) {
        if (!node) { hideTooltip(); return; }
        const ttLabel = tooltip.querySelector('.tt-label');
        const ttLabels = tooltip.querySelector('.tt-labels');
        const ttProps = tooltip.querySelector('.tt-props');

        ttLabel.textContent = node.label;
        ttLabels.innerHTML = (node.labels || []).map(l =>
          `<span class="tag" style="background:${hexToAlpha(state.labelColors[l] || '#888', 0.2)};color:${state.labelColors[l] || '#888'};border-color:${hexToAlpha(state.labelColors[l] || '#888', 0.3)}">${l}</span>`
        ).join(' ');

        const props = node.properties || {};
        const propKeys = Object.keys(props).slice(0, 12);
        ttProps.innerHTML = propKeys.map(k => {
          let v = props[k];
          if (typeof v === 'object') v = JSON.stringify(v);
          if (typeof v === 'string' && v.length > 60) v = v.substring(0, 57) + '…';
          return `<div><span class="key">${k}:</span> <span class="val">${escHtml(String(v))}</span></div>`;
        }).join('');

        tooltip.classList.add('visible');
        const x = event.clientX + 14;
        const y = event.clientY - 10;
        const graphRect = document.getElementById('graph-area').getBoundingClientRect();
        tooltip.style.left = Math.min(x, graphRect.right - 380) + 'px';
        tooltip.style.top = Math.min(y, graphRect.bottom - 200) + 'px';
      }

      function hideTooltip() {
        tooltip.classList.remove('visible');
      }

      /* =================================================================
         DRAW — Canvas Rendering
         ================================================================= */
      function draw() {
        ctx.save();
        ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
        ctx.clearRect(0, 0, width, height);

        // Background gradient
        const bgGrad = ctx.createRadialGradient(width / 2, height / 2, 0, width / 2, height / 2, Math.max(width, height) * 0.7);
        bgGrad.addColorStop(0, '#0e1230');
        bgGrad.addColorStop(1, '#060a1a');
        ctx.fillStyle = bgGrad;
        ctx.fillRect(0, 0, width, height);

        // Subtle grid
        drawGrid();

        // Apply transform
        ctx.translate(transform.x, transform.y);
        ctx.scale(transform.k, transform.k);

        const isHighlighting = state.highlightedNodes.size > 0;
        const maxHopInHighlight = isHighlighting ? Math.max(...state.highlightedEdges.values(), 1) : 1;
        const nodeCount = state.nodes.length;
        const edgeCount = state.edges.length;

        // Build node lookup for edge drawing
        const nodeMap = state._nodeMap;
        const V = state.vis;

        // === EDGES ===
        const autoAlpha = Math.max(0.03, Math.min(0.35, 30 / Math.sqrt(Math.max(edgeCount, 1))));
        const baseAlpha = V.edgeOpacity > 0 ? V.edgeOpacity : autoAlpha;
        const baseLw = Math.max(0.3, Math.min(1.5, 2 / Math.max(transform.k, 0.2))) * V.edgeWidth;
        const curvature = V.curvature;

        // Edge color palette by relationship type
        const relColorCache = {};
        function relColor(type) {
          if (relColorCache[type]) return relColorCache[type];
          let hash = 0;
          for (let i = 0; i < type.length; i++) hash = ((hash << 5) - hash + type.charCodeAt(i)) | 0;
          const h = ((hash % 360) + 360) % 360;
          relColorCache[type] = `hsla(${h}, 50%, 60%, `;
          return relColorCache[type];
        }

        function hexToRgba(hex, alpha) {
          hex = hex.replace('#', '');
          const r = parseInt(hex.substring(0, 2), 16);
          const g = parseInt(hex.substring(2, 4), 16);
          const b = parseInt(hex.substring(4, 6), 16);
          return `rgba(${r},${g},${b},${alpha})`;
        }

        // Detect multi-edges for curving
        const edgePairCount = {};
        const edgePairIndex = {};
        for (let i = 0; i < edgeCount; i++) {
          const e = state.edges[i];
          const pairKey = e.source < e.target ? e.source + '|' + e.target : e.target + '|' + e.source;
          edgePairCount[pairKey] = (edgePairCount[pairKey] || 0) + 1;
          edgePairIndex[i] = edgePairCount[pairKey] - 1;
        }

        for (let i = 0; i < edgeCount; i++) {
          const e = state.edges[i];
          const src = nodeMap[e.source];
          const tgt = nodeMap[e.target];
          if (!src || !tgt) continue;

          const sx = src.x, sy = src.y, tx = tgt.x, ty = tgt.y;
          if (!lineVisible(sx, sy, tx, ty)) continue;

          let alpha = baseAlpha;
          let lw = baseLw;

          if (isHighlighting) {
            if (state.highlightedEdges.has(i)) {
              const hop = state.highlightedEdges.get(i);
              const fade = Math.pow(0.35, hop);  // steeper exponential decay per hop
              const maxAlpha = V.edgeOpacity > 0 ? V.edgeOpacity : 0.9;
              alpha = maxAlpha * fade;
              lw *= 1 + 1.5 * fade;
            } else {
              alpha *= 0.04;
            }
          }

          // Determine curve offset for multi-edges + global curvature
          const pairKey = e.source < e.target ? e.source + '|' + e.target : e.target + '|' + e.source;
          const pairTotal = edgePairCount[pairKey] || 1;
          const pairIdx = edgePairIndex[i] || 0;

          const dx = tx - sx;
          const dy = ty - sy;
          const dist = Math.sqrt(dx * dx + dy * dy + 0.01);
          const nx_ = -dy / dist;  // perpendicular normal
          const ny_ = dx / dist;

          // Multi-edge spread + base curvature
          let offset = 0;
          if (pairTotal > 1) {
            offset = (pairIdx - (pairTotal - 1) / 2) * 30 * Math.max(curvature, 0.3);
          } else if (curvature > 0) {
            // Single edge: use a deterministic offset based on edge index
            const sign = (i % 2 === 0) ? 1 : -1;
            offset = sign * curvature * dist * 0.15;
          }

          const mx = (sx + tx) / 2;
          const my = (sy + ty) / 2;
          const cpx = mx + nx_ * offset;
          const cpy = my + ny_ * offset;

          // Color based on scheme
          let strokeStyle;
          if (V.edgeColorScheme === 'uniform') {
            strokeStyle = hexToRgba(V.edgeColor, alpha);
          } else if (V.edgeColorScheme === 'gradient' && src.color && tgt.color) {
            const grad = ctx.createLinearGradient(sx, sy, tx, ty);
            grad.addColorStop(0, hexToAlpha(src.color, alpha));
            grad.addColorStop(1, hexToAlpha(tgt.color, alpha));
            strokeStyle = grad;
          } else {
            strokeStyle = relColor(e.type || 'RELATED') + alpha + ')';
          }

          ctx.beginPath();
          ctx.strokeStyle = strokeStyle;
          ctx.lineWidth = lw;

          if (Math.abs(offset) > 0.5) {
            ctx.moveTo(sx, sy);
            ctx.quadraticCurveTo(cpx, cpy, tx, ty);
          } else {
            ctx.moveTo(sx, sy);
            ctx.lineTo(tx, ty);
          }
          ctx.stroke();

          // Draw relationship type label on highlighted edges when zoomed in (first 2 hops only)
          if (isHighlighting && state.highlightedEdges.has(i) && state.highlightedEdges.get(i) <= 2 && transform.k > 1.0) {
            // Label at the curve midpoint
            const lx = (pairTotal > 1 || curvature > 0) ? (sx + 2 * cpx + tx) / 4 : mx;
            const ly = (pairTotal > 1 || curvature > 0) ? (sy + 2 * cpy + ty) / 4 : my;
            const fontSize = Math.max(6, Math.min(10, 8 / Math.max(transform.k * 0.3, 0.5)));
            ctx.font = `500 ${fontSize}px Inter, sans-serif`;
            ctx.textAlign = 'center';
            ctx.textBaseline = 'middle';
            const colorBase = relColor(e.type || 'RELATED');
            ctx.fillStyle = colorBase + '0.9)';
            ctx.shadowColor = 'rgba(0,0,0,0.9)';
            ctx.shadowBlur = 3;
            ctx.fillText(e.type || '', lx, ly - 6);
            ctx.shadowBlur = 0;
          }
        }

        // === NODES ===
        const showLabels = transform.k > V.labelZoom && nodeCount < 2000;
        const showLabelsAlways = transform.k > V.labelZoom * 2;

        for (const node of state.nodes) {
          // Frustum culling
          const sx = node.x * transform.k + transform.x;
          const sy = node.y * transform.k + transform.y;
          if (sx < -100 || sx > width + 100 || sy < -100 || sy > height + 100) continue;

          const r = Math.max(1.5, node.size * V.nodeSize * Math.min(transform.k * 0.5, 4));
          const isHighlighted = state.highlightedNodes.has(node.id);
          const isHovered = node === state.hoveredNode;
          const isSelected = node === state.selectedNode;

          let alpha = 1;
          if (isHighlighting) {
            if (isHighlighted) {
              const hop = state.highlightedNodes.get(node.id);
              alpha = Math.max(0.15, Math.pow(0.6, hop));
            } else {
              alpha = 0.06;
            }
          }

          ctx.globalAlpha = alpha;

          // Glow for hovered/selected/highlighted nodes
          if ((isHovered || isSelected || (isHighlighted && isHighlighting)) && alpha > 0.5) {
            ctx.save();
            ctx.shadowColor = node.color;
            ctx.shadowBlur = isHovered ? 25 : isSelected ? 20 : 12;
            ctx.beginPath();
            ctx.arc(node.x, node.y, r * 1.3, 0, Math.PI * 2);
            ctx.fillStyle = node.color;
            ctx.globalAlpha = alpha * 0.35;
            ctx.fill();
            ctx.restore();
            ctx.globalAlpha = alpha;
          }

          // Node body — radial gradient
          const grad = ctx.createRadialGradient(
            node.x - r * 0.25, node.y - r * 0.25, 0,
            node.x, node.y, r
          );
          grad.addColorStop(0, lightenColor(node.color, 50));
          grad.addColorStop(1, node.color);

          ctx.beginPath();
          ctx.arc(node.x, node.y, r, 0, Math.PI * 2);
          ctx.fillStyle = grad;
          ctx.fill();

          // Bright edge
          ctx.strokeStyle = `rgba(255,255,255,${alpha * 0.25})`;
          ctx.lineWidth = 0.5;
          ctx.stroke();

          // Label — in highlight mode only show for hop 0-1; sliders always apply
          const hopDist = isHighlighted ? state.highlightedNodes.get(node.id) : Infinity;
          const nearFocus = isHighlighting && isHighlighted && hopDist <= 1;
          const normalShow = showLabelsAlways || (showLabels && r > 3);
          const showLabel = alpha > 0.1 && (isHovered || isSelected || nearFocus || (!isHighlighting && normalShow) || (isHighlighting && normalShow && isHighlighted && hopDist <= 1));
          if (showLabel) {
            const fontSize = Math.max(4, Math.min(28, r * 1.2 * V.labelSize));
            ctx.font = `500 ${fontSize}px Inter, sans-serif`;
            ctx.textAlign = 'center';
            ctx.textBaseline = 'top';
            ctx.fillStyle = `rgba(255,255,255,${alpha * 0.9})`;
            ctx.shadowColor = 'rgba(0,0,0,0.8)';
            ctx.shadowBlur = 4;
            const lines = wrapLabel(node.label, 20);
            const lh = fontSize * 1.25;
            for (let li = 0; li < lines.length; li++) {
              ctx.fillText(lines[li], node.x, node.y + r + 3 + li * lh);
            }
            ctx.shadowBlur = 0;
          }

          ctx.globalAlpha = 1;
        }

        ctx.restore();

        // Minimap
        drawMinimap();

        requestAnimationFrame(() => { }); // keep event loop alive
      }

      function drawGrid() {
        const gridSize = 60;
        const alpha = Math.min(0.06, 0.03 * transform.k);
        if (alpha < 0.005) return;
        ctx.strokeStyle = `rgba(100,140,200,${alpha})`;
        ctx.lineWidth = 0.5;

        const startX = (-transform.x / transform.k);
        const startY = (-transform.y / transform.k);
        const endX = startX + width / transform.k;
        const endY = startY + height / transform.k;
        const gs = gridSize / Math.max(transform.k, 0.05);

        const gStartX = Math.floor(startX / gs) * gs;
        const gStartY = Math.floor(startY / gs) * gs;

        ctx.save();
        ctx.translate(transform.x, transform.y);
        ctx.scale(transform.k, transform.k);

        for (let x = gStartX; x <= endX; x += gs) {
          ctx.beginPath();
          ctx.moveTo(x, startY);
          ctx.lineTo(x, endY);
          ctx.stroke();
        }
        for (let y = gStartY; y <= endY; y += gs) {
          ctx.beginPath();
          ctx.moveTo(startX, y);
          ctx.lineTo(endX, y);
          ctx.stroke();
        }
        ctx.restore();
      }

      function lineVisible(x1, y1, x2, y2) {
        const sx1 = x1 * transform.k + transform.x;
        const sy1 = y1 * transform.k + transform.y;
        const sx2 = x2 * transform.k + transform.x;
        const sy2 = y2 * transform.k + transform.y;
        // Broad check
        const margin = 50;
        if (Math.max(sx1, sx2) < -margin) return false;
        if (Math.min(sx1, sx2) > width + margin) return false;
        if (Math.max(sy1, sy2) < -margin) return false;
        if (Math.min(sy1, sy2) > height + margin) return false;
        return true;
      }

      /* =================================================================
         MINIMAP
         ================================================================= */
      function drawMinimap() {
        if (!state.nodes.length) return;
        const mw = mmCanvas.width / dpr;
        const mh = mmCanvas.height / dpr;

        mmCtx.save();
        mmCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
        mmCtx.clearRect(0, 0, mw, mh);
        mmCtx.fillStyle = 'rgba(6,10,26,0.9)';
        mmCtx.fillRect(0, 0, mw, mh);

        // Compute bounds
        let minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
        for (const n of state.nodes) {
          if (n.x < minX) minX = n.x;
          if (n.x > maxX) maxX = n.x;
          if (n.y < minY) minY = n.y;
          if (n.y > maxY) maxY = n.y;
        }
        const pad = 20;
        const gw = (maxX - minX) || 1;
        const gh = (maxY - minY) || 1;
        const scale = Math.min((mw - 2 * pad) / gw, (mh - 2 * pad) / gh);
        const ox = (mw - gw * scale) / 2;
        const oy = (mh - gh * scale) / 2;

        // Draw nodes
        const dotSize = Math.max(0.5, Math.min(2, 1));
        for (const n of state.nodes) {
          const x = (n.x - minX) * scale + ox;
          const y = (n.y - minY) * scale + oy;
          mmCtx.fillStyle = n.color;
          mmCtx.globalAlpha = 0.7;
          mmCtx.fillRect(x - dotSize / 2, y - dotSize / 2, dotSize, dotSize);
        }
        mmCtx.globalAlpha = 1;

        // Viewport rectangle
        const vx1 = (-transform.x / transform.k - minX) * scale + ox;
        const vy1 = (-transform.y / transform.k - minY) * scale + oy;
        const vw = (width / transform.k) * scale;
        const vh = (height / transform.k) * scale;
        mmCtx.strokeStyle = 'rgba(0,212,255,0.6)';
        mmCtx.lineWidth = 1;
        mmCtx.strokeRect(vx1, vy1, vw, vh);

        mmCtx.restore();
      }

      /* =================================================================
         DATA LOADING
         ================================================================= */
      function loadGraphData(data) {
        state.nodes = data.nodes || [];
        state.edges = data.edges || [];
        state.records = data.records || [];
        state.keys = data.keys || [];
        state.labelColors = data.label_colors || {};
        state.hoveredNode = null;
        state.selectedNode = null;
        state.highlightedNodes = new Map();
        state.highlightedEdges = new Map();

        // Build node map
        state._nodeMap = {};
        for (const n of state.nodes) {
          state._nodeMap[n.id] = n;
        }

        // Build edge index
        state.edgeIndex = {};
        for (let i = 0; i < state.edges.length; i++) {
          const e = state.edges[i];
          if (!state.edgeIndex[e.source]) state.edgeIndex[e.source] = [];
          if (!state.edgeIndex[e.target]) state.edgeIndex[e.target] = [];
          state.edgeIndex[e.source].push(i);
          state.edgeIndex[e.target].push(i);
        }

        // Build quadtree for hit testing
        state.quadtree = d3.quadtree()
          .x(d => d.x)
          .y(d => d.y)
          .addAll(state.nodes);

        // Update legend
        updateLegend();
        // Update stats
        updateStats(data.stats);
        // Update table
        updateTable();

        // Fit view
        setTimeout(() => fitGraph(), 100);
        draw();
      }

      /* =================================================================
         LEGEND
         ================================================================= */
      function updateLegend() {
        const el = document.getElementById('legend-body');
        const entries = Object.entries(state.labelColors);
        if (!entries.length) {
          el.innerHTML = '<div style="color:var(--text-muted);font-size:12px">No labels</div>';
          return;
        }
        el.innerHTML = entries.map(([label, color]) => {
          const count = state.nodes.filter(n => n.labels && n.labels.includes(label)).length;
          return `<div class="legend-item">
      <div class="legend-dot" style="background:${color};box-shadow:0 0 6px ${color}"></div>
      <span>${escHtml(label)}</span>
      <span style="color:var(--text-muted);font-size:11px;margin-left:auto">${count}</span>
    </div>`;
        }).join('');
      }

      function updateStats(stats) {
        if (!stats) return;
        document.getElementById('stat-nodes').textContent = stats.node_count?.toLocaleString() || '0';
        document.getElementById('stat-edges').textContent = stats.edge_count?.toLocaleString() || '0';
        document.getElementById('stat-query-ms').textContent = stats.query_time_ms ? stats.query_time_ms + 'ms' : '–';
        document.getElementById('stat-layout-ms').textContent = stats.layout_time_ms ? stats.layout_time_ms + 'ms' : '–';
      }

      /* =================================================================
         TABLE VIEW
         ================================================================= */
      function updateTable() {
        const el = document.getElementById('table-view');
        if (!state.records.length || !state.keys.length) {
          el.innerHTML = '<div style="color:var(--text-muted);padding:40px;text-align:center">No tabular data</div>';
          return;
        }
        let html = '<table><thead><tr>';
        for (const k of state.keys) {
          html += `<th>${escHtml(k)}</th>`;
        }
        html += '</tr></thead><tbody>';
        for (const row of state.records.slice(0, 500)) {
          html += '<tr>';
          for (const k of state.keys) {
            let v = row[k];
            if (v && typeof v === 'object') v = JSON.stringify(v);
            html += `<td title="${escHtml(String(v ?? ''))}">${escHtml(String(v ?? ''))}</td>`;
          }
          html += '</tr>';
        }
        html += '</tbody></table>';
        el.innerHTML = html;
      }

      /* =================================================================
         VIEW TOGGLE
         ================================================================= */
      window.setView = function (view) {
        state.currentView = view;
        document.getElementById('btn-graph-view').classList.toggle('active', view === 'graph');
        document.getElementById('btn-table-view').classList.toggle('active', view === 'table');
        document.getElementById('table-view').classList.toggle('active', view === 'table');
        canvas.style.display = view === 'graph' ? 'block' : 'none';
        document.getElementById('minimap').style.display = view === 'graph' ? 'block' : 'none';
        if (view === 'graph') draw();
      };

      /* =================================================================
         SEARCH
         ================================================================= */
      window.searchNodes = function (query) {
        const el = document.getElementById('search-results');
        if (!query || query.length < 2) { el.innerHTML = ''; return; }
        const q = query.toLowerCase();
        const matches = state.nodes.filter(n => n.label.toLowerCase().includes(q)).slice(0, 20);
        el.innerHTML = matches.map(n => `
    <div class="sample-query" onclick="focusNode('${n.id}')" style="font-family:var(--mono);">
      <span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${n.color};margin-right:6px"></span>
      ${escHtml(n.label)}
    </div>
  `).join('');
      };

      window.focusNode = function (nodeId) {
        const node = state._nodeMap[nodeId];
        if (!node) return;
        state.selectedNode = node;
        updateHighlight();
        const scale = 3;
        const tx = width / 2 - node.x * scale;
        const ty = height / 2 - node.y * scale;
        d3.select(canvas).transition().duration(600)
          .call(zoomBehavior.transform, d3.zoomIdentity.translate(tx, ty).scale(scale));
      };

      /* =================================================================
         API CALLS
         ================================================================= */
      window.runQuery = async function () {
        const editor = document.getElementById('query-editor');
        const query = editor.value.trim();
        if (!query) return;

        const layout = document.getElementById('layout-select').value;
        const spacing = state.vis.spacing;
        const iterations = state.vis.iterations;

        showLoading('Executing query…');
        try {
          const resp = await fetch('/api/query', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ query, layout, spacing, iterations }),
          });
          const data = await resp.json();
          if (data.error) throw new Error(data.error);
          loadGraphData(data);
          hideLoading();
        } catch (err) {
          hideLoading();
          showError(err.message);
        }
      };

      window.runDemo = async function () {
        const size = parseInt(document.getElementById('demo-size').value);
        const layout = document.getElementById('layout-select').value;
        const spacing = state.vis.spacing;
        const iterations = state.vis.iterations;
        showLoading(`Generating demo graph (${size} nodes)…`);
        try {
          const resp = await fetch('/api/demo', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ size, layout, spacing, iterations }),
          });
          const data = await resp.json();
          if (data.error) throw new Error(data.error);
          loadGraphData(data);
          hideLoading();
        } catch (err) {
          hideLoading();
          showError(err.message);
        }
      };

      async function testConnection() {
        const errDetail = document.getElementById('conn-error-detail');
        try {
          const resp = await fetch('/api/connection-test');
          const data = await resp.json();
          const el = document.getElementById('connection-status');
          const txt = document.getElementById('conn-text');
          if (data.status === 'connected') {
            el.className = '';
            el.classList.add('ok');
            txt.textContent = 'Connected to Neo4j';
            errDetail.style.display = 'none';
          } else {
            el.className = '';
            el.classList.add('err');
            txt.textContent = 'Connection failed';
            errDetail.textContent = data.message || 'Unknown error';
            errDetail.style.display = 'block';
          }
        } catch (e) {
          const el = document.getElementById('connection-status');
          el.className = '';
          el.classList.add('err');
          document.getElementById('conn-text').textContent = 'Connection error';
          errDetail.textContent = e.message;
          errDetail.style.display = 'block';
        }
      }

      window.reconnect = async function () {
        const uri = document.getElementById('conn-uri').value.trim();
        const user = document.getElementById('conn-user').value.trim();
        const pass = document.getElementById('conn-pass').value;
        document.getElementById('conn-text').textContent = 'Reconnecting…';
        document.getElementById('conn-error-detail').style.display = 'none';
        try {
          const resp = await fetch('/api/reconnect', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({ uri, user, password: pass }),
          });
          const data = await resp.json();
          if (data.status === 'connected') {
            document.getElementById('connection-status').className = 'ok';
            document.getElementById('conn-text').textContent = 'Connected to Neo4j';
            loadSchema();
          } else {
            document.getElementById('connection-status').className = 'err';
            document.getElementById('conn-text').textContent = 'Connection failed';
            const errDetail = document.getElementById('conn-error-detail');
            errDetail.textContent = data.message || 'Unknown error';
            errDetail.style.display = 'block';
          }
        } catch (e) {
          document.getElementById('connection-status').className = 'err';
          document.getElementById('conn-text').textContent = 'Connection error';
        }
      };

      async function loadSchema() {
        try {
          const resp = await fetch('/api/schema');
          const data = await resp.json();
          if (data.error) throw new Error(data.error);
          const el = document.getElementById('schema-body');
          let html = '';
          if (data.labels?.length) {
            html += '<div style="margin-bottom:8px;font-size:11px;color:var(--text-dim);font-weight:600">NODE LABELS</div>';
            html += '<div class="tag-list">';
            data.labels.forEach(l => html += `<span class="tag">${escHtml(l)}</span>`);
            html += '</div>';
          }
          if (data.relationship_types?.length) {
            html += '<div style="margin:12px 0 8px;font-size:11px;color:var(--text-dim);font-weight:600">RELATIONSHIPS</div>';
            html += '<div class="tag-list">';
            data.relationship_types.forEach(r => html += `<span class="tag rel">${escHtml(r)}</span>`);
            html += '</div>';
          }
          el.innerHTML = html || '<div style="color:var(--text-muted);font-size:12px">Empty schema</div>';
        } catch {
          document.getElementById('schema-body').innerHTML = '<div style="color:var(--danger);font-size:12px">Failed to load schema</div>';
        }
      }

      async function loadSampleQueries() {
        try {
          const resp = await fetch('/api/sample-queries');
          const queries = await resp.json();
          const el = document.getElementById('sample-queries-list');
          el.innerHTML = queries.map(q => `
      <div class="sample-query" onclick="loadSample(this)" data-query="${escAttr(q.query)}">
        ${escHtml(q.name)}
      </div>
    `).join('');
        } catch { }
      }

      async function loadLayouts() {
        try {
          const resp = await fetch('/api/layouts');
          const layouts = await resp.json();
          const sel = document.getElementById('layout-select');
          sel.innerHTML = '';
          layouts.forEach(l => {
            const opt = document.createElement('option');
            opt.value = l.id;
            opt.textContent = l.name;
            opt.title = l.description;
            sel.appendChild(opt);
          });
        } catch { }
      }

      window.loadSample = function (el) {
        const query = el.getAttribute('data-query');
        document.getElementById('query-editor').value = query;
      };

      /* =================================================================
         PANEL TOGGLES
         ================================================================= */
      function setupPanelToggles() {
        document.querySelectorAll('.panel-head').forEach(head => {
          head.addEventListener('click', () => {
            head.classList.toggle('collapsed');
            const panel = head.getAttribute('data-panel');
            document.querySelector(`.panel-body[data-panel="${panel}"]`).classList.toggle('collapsed');
          });
        });
      }

      /* =================================================================
         KEYBOARD SHORTCUTS
         ================================================================= */
      function setupKeyboard() {
        document.addEventListener('keydown', (e) => {
          // Ctrl/Cmd + Enter to run query
          if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
            e.preventDefault();
            runQuery();
          }
          // Escape to deselect
          if (e.key === 'Escape') {
            state.selectedNode = null;
            state.hoveredNode = null;
            updateHighlight();
            draw();
            hideTooltip();
          }
        });
      }

      /* =================================================================
         VISUAL SETTINGS
         ================================================================= */
      window.updateVisual = function (key, value) {
        const num = parseFloat(value);
        state.vis[key] = isNaN(num) ? value : num;
        // Update display label
        const label = document.getElementById('sv-' + key.replace(/([A-Z])/g, '-$1').toLowerCase());
        if (label) {
          if (key === 'edgeOpacity' && parseFloat(value) === 0) {
            label.textContent = 'auto';
          } else {
            label.textContent = isNaN(num) ? value : num.toFixed(key === 'iterations' ? 0 : (key === 'spacing' ? 1 : 2));
          }
        }
        // Show/hide uniform color picker
        if (key === 'edgeColorScheme') {
          document.getElementById('uniform-color-row').style.display = value === 'uniform' ? 'flex' : 'none';
        }
        draw();
      };

      /* =================================================================
         UI HELPERS
         ================================================================= */
      function showLoading(text) {
        document.getElementById('loading-text').textContent = text || 'Loading…';
        document.getElementById('loading-overlay').classList.add('active');
        document.getElementById('run-btn').disabled = true;
      }

      function hideLoading() {
        document.getElementById('loading-overlay').classList.remove('active');
        document.getElementById('run-btn').disabled = false;
      }

      let errorTimeout;
      function showError(msg) {
        const el = document.getElementById('error-toast');
        el.textContent = msg;
        el.classList.add('visible');
        clearTimeout(errorTimeout);
        errorTimeout = setTimeout(() => el.classList.remove('visible'), 6000);
      }

      window.toggleSidebar = function () {
        document.getElementById('sidebar').classList.toggle('collapsed');
        setTimeout(() => { resizeCanvas(); }, 300);
      };

      /* =================================================================
         COLOR UTILITIES
         ================================================================= */
      function wrapLabel(text, maxLen) {
        if (!text || text.length <= maxLen) return [text || ''];
        // Try to break at a space near maxLen
        let breakIdx = text.lastIndexOf(' ', maxLen);
        if (breakIdx < 4) breakIdx = maxLen; // no good space, hard break
        const line1 = text.substring(0, breakIdx).trim();
        let line2 = text.substring(breakIdx).trim();
        if (line2.length > maxLen) line2 = line2.substring(0, maxLen - 1) + '\u2026';
        return [line1, line2];
      }

      function lightenColor(hex, amount) {
        hex = hex.replace('#', '');
        let r = parseInt(hex.substring(0, 2), 16);
        let g = parseInt(hex.substring(2, 4), 16);
        let b = parseInt(hex.substring(4, 6), 16);
        r = Math.min(255, r + amount);
        g = Math.min(255, g + amount);
        b = Math.min(255, b + amount);
        return `rgb(${r},${g},${b})`;
      }

      function hexToAlpha(hex, alpha) {
        hex = hex.replace('#', '');
        const r = parseInt(hex.substring(0, 2), 16);
        const g = parseInt(hex.substring(2, 4), 16);
        const b = parseInt(hex.substring(4, 6), 16);
        return `rgba(${r},${g},${b},${alpha})`;
      }

      function escHtml(s) {
        const map = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#039;' };
        return s.replace(/[&<>"']/g, m => map[m]);
      }

      function escAttr(s) {
        return s.replace(/"/g, '&quot;').replace(/'/g, '&#039;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
      }

      /* =================================================================
         BOOT
         ================================================================= */
      init();

    })();
  </script>
</body>

</html>