Star-Mapper/templates/index.html

2313 lines
78 KiB
HTML
Raw Permalink Normal View History

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-10 15:48:18 +00:00
CSS — Graph Explorer
2026-03-06 16:34:01 +00:00
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)&#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>
2026-03-10 15:48:18 +00:00
<button class="btn btn-secondary" onclick="reLayout()" title="Re-run layout on cached data (no DB query)">⟳ Re-Layout</button>
2026-03-07 13:24:32 +00:00
<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">
2026-03-10 15:48:18 +00:00
<button class="btn btn-secondary" onclick="runDemo()" title="Generate demo graph without Neo4j">✦ Demo</button>
2026-03-07 13:24:32 +00:00
<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 &amp; 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 &amp; 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>
2026-03-10 15:48:18 +00:00
<button onclick="exportPNG()" title="Export as PNG">📷</button>
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
<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');
2026-03-10 15:48:18 +00:00
let dpr = Math.max(window.devicePixelRatio || 1, 2);
2026-03-07 13:24:32 +00:00
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);
};
2026-03-10 15:48:18 +00:00
window.exportPNG = function () {
// Export the current canvas at its full backing-store resolution (2x+)
canvas.toBlob(function(blob) {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'graph-export.png';
a.click();
URL.revokeObjectURL(url);
}, 'image/png');
};
2026-03-07 13:24:32 +00:00
/* =================================================================
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);
}
};
2026-03-10 15:48:18 +00:00
window.reLayout = async function () {
const layout = document.getElementById('layout-select').value;
const spacing = state.vis.spacing;
const iterations = state.vis.iterations;
showLoading('Re-computing layout…');
try {
const resp = await fetch('/api/relayout', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ 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);
}
};
2026-03-07 13:24:32 +00:00
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 = { '&': '&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>
2026-03-06 16:34:01 +00:00
</body>
2026-03-07 13:24:32 +00:00
</html>