NeuroOracle / core /web /static /explore.html
zxcvb20001's picture
Deploy: atom-aligned KG + UI
dc6bcac verified
Raw
History Blame Contribute Delete
103 kB
<!DOCTYPE html>
<html lang="en" data-theme="light">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NeuroClaw · Knowledge Graph Explorer</title>
<script src="https://cdn.jsdelivr.net/npm/graphology@0.25.4/dist/graphology.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sigma@3.0.0/dist/sigma.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/graphology-layout-forceatlas2@0.10.1/worker.min.js"></script>
<style>
*,*::before,*::after { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--radius-lg: 14px;
--radius-md: 10px;
--radius-sm: 6px;
--header-h: 60px;
--sidebar-w: 340px;
--detail-w: 420px;
--shadow-card: 0 1px 3px rgba(15, 23, 42, 0.06), 0 1px 2px rgba(15, 23, 42, 0.04);
--ease: 200ms cubic-bezier(0.2, 0.8, 0.2, 1);
--ui-font-size: 22px;
--graph-label-size: 32px;
}
html[data-theme="light"] {
--bg: #f5f7fa;
--surface: #ffffff;
--surface-soft: #f1f4f9;
--surface-elev: #ffffff;
--border: #e2e8f0;
--border-strong: #cbd5e1;
--accent: #0a84ff;
--accent-soft: rgba(10, 132, 255, 0.1);
--text: #0f172a;
--text-muted: #64748b;
--text-subtle: #94a3b8;
--danger: #dc2626;
--warning: #d97706;
--success: #10b981;
}
html[data-theme="dark"] {
--bg: #0b1220;
--surface: #111a2c;
--surface-soft: #0e1626;
--surface-elev: #152037;
--border: #1f2d45;
--border-strong: #2b3d5c;
--accent: #58a6ff;
--accent-soft: rgba(88, 166, 255, 0.15);
--text: #e2e8f0;
--text-muted: #8da0b4;
--text-subtle: #64748b;
--danger: #ef4444;
--warning: #f59e0b;
--success: #10b981;
}
body {
font-family: -apple-system, BlinkMacSystemFont, "SF Pro Text", "PingFang SC",
"Helvetica Neue", Arial, sans-serif;
background: var(--bg);
color: var(--text);
min-height: 100vh;
font-size: var(--ui-font-size);
line-height: 1.5;
overflow: hidden;
}
/* ── Header ─────────────────────────────────────────────────────────── */
.header {
height: var(--header-h);
background: var(--surface);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 22px;
gap: 18px;
position: sticky;
top: 0;
z-index: 10;
}
.header-brand {
display: flex;
align-items: center;
gap: 11px;
font-weight: 600;
font-size: 16px;
}
.header-brand .logo-dot {
width: 28px;
height: 28px;
border-radius: 50%;
background: linear-gradient(135deg, #0a84ff, #8b5cf6);
}
.header-sub {
font-size: 13px;
color: var(--text-muted);
border-left: 1px solid var(--border);
padding-left: 18px;
}
.stats-line {
margin-left: auto;
font-size: 12.5px;
color: var(--text-muted);
display: flex;
gap: 10px;
align-items: center;
}
.stats-dot {
width: 3px;
height: 3px;
border-radius: 50%;
background: var(--text-subtle);
}
.icon-btn {
background: transparent;
border: 1px solid var(--border);
color: var(--text-muted);
padding: 7px 11px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 12.5px;
transition: all var(--ease);
display: inline-flex;
align-items: center;
gap: 5px;
}
.icon-btn:hover {
border-color: var(--accent);
color: var(--accent);
}
.lang-toggle { font-weight: 600; letter-spacing: 0.04em; }
.back-btn {
padding: 5px 11px;
font-size: 12.5px;
}
.back-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
border-color: var(--border);
color: var(--text-subtle);
}
.back-btn:disabled:hover {
border-color: var(--border);
color: var(--text-subtle);
}
/* ── Layout ─────────────────────────────────────────────────────────── */
.layout {
display: grid;
grid-template-columns: var(--sidebar-w) minmax(0, 1fr) var(--detail-w);
height: calc(100vh - var(--header-h));
overflow: hidden;
}
/* ── Sidebar ────────────────────────────────────────────────────────── */
.sidebar {
background: var(--surface);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
}
.search-box {
padding: 16px;
border-bottom: 1px solid var(--border);
}
.search-input {
width: 100%;
padding: 10px 13px;
background: var(--surface-soft);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
color: var(--text);
font-size: 14px;
outline: none;
transition: all var(--ease);
}
.search-input:focus {
border-color: var(--accent);
background: var(--surface);
}
.domain-chips {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-top: 11px;
}
.chip {
padding: 3px 10px;
border-radius: 12px;
font-size: 12px;
background: var(--surface-soft);
border: 1px solid var(--border);
color: var(--text-muted);
cursor: pointer;
transition: all var(--ease);
user-select: none;
}
.chip:hover { border-color: var(--border-strong); }
.chip.active {
background: var(--accent-soft);
border-color: var(--accent);
color: var(--accent);
}
.pill {
padding: 3px 9px;
border-radius: 11px;
font-size: 12px;
background: var(--surface-soft);
border: 1px solid var(--border);
color: var(--text-muted);
cursor: pointer;
user-select: none;
transition: all var(--ease);
}
.pill:hover { border-color: var(--border-strong); color: var(--text); }
.pill.active {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.results {
flex: 1;
overflow-y: auto;
padding: 8px 0;
}
.results-hint {
padding: 18px 20px;
color: var(--text-subtle);
font-size: 12.5px;
text-align: center;
}
.result-item {
padding: 11px 15px;
cursor: pointer;
border-left: 2px solid transparent;
transition: all var(--ease);
}
.result-item:hover { background: var(--surface-soft); }
.result-item.active {
background: var(--accent-soft);
border-left-color: var(--accent);
}
.result-name {
font-size: 13.5px;
font-weight: 500;
color: var(--text);
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.result-meta {
display: flex;
gap: 6px;
margin-top: 6px;
font-size: 11.5px;
color: var(--text-muted);
flex-wrap: wrap;
align-items: center;
}
.result-meta .badge {
background: var(--surface-soft);
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
border: 1px solid var(--border);
}
.result-meta .domain-tag {
background: var(--surface-soft);
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
border: 1px solid var(--border);
color: #fff;
border-color: transparent;
}
.result-item.is-noise .result-name { color: var(--text-muted); }
.noise-mini {
display: inline-block;
width: 5px;
height: 5px;
background: var(--warning);
border-radius: 50%;
margin-left: 4px;
vertical-align: middle;
opacity: 0.7;
}
/* ── Graph ──────────────────────────────────────────────────────────── */
.graph-pane {
display: flex;
flex-direction: column;
background: var(--bg);
overflow: hidden;
position: relative;
}
.graph-controls {
display: flex;
gap: 12px;
padding: 11px 18px;
background: var(--surface);
border-bottom: 1px solid var(--border);
align-items: center;
flex-wrap: wrap;
}
.graph-controls label {
display: flex;
align-items: center;
gap: 6px;
font-size: 12.5px;
color: var(--text-muted);
}
.graph-controls select {
background: var(--surface-soft);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 5px 9px;
font-size: 12.5px;
color: var(--text);
cursor: pointer;
}
.graph-info {
margin-left: auto;
font-size: 11.5px;
color: var(--text-subtle);
}
.graph-canvas {
flex: 1;
position: relative;
overflow: hidden;
}
#sigma-container {
position: absolute;
inset: 0;
}
.graph-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-subtle);
font-size: 14px;
text-align: center;
padding: 40px;
}
.legend {
position: absolute;
bottom: 14px;
left: 14px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 14px 20px;
font-size: 22px;
display: flex;
flex-direction: column;
gap: 10px;
max-width: 1100px;
box-shadow: var(--shadow-card);
transition: transform 0.3s ease, opacity 0.3s ease;
transform: translateY(0);
}
.legend.collapsed {
transform: translateY(calc(100% + 14px));
opacity: 0;
pointer-events: none;
}
.legend-toggle {
position: absolute;
bottom: 14px;
left: 14px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 8px 14px;
font-size: 16px;
color: var(--text-muted);
cursor: pointer;
box-shadow: var(--shadow-card);
transition: opacity 0.3s ease;
opacity: 0;
pointer-events: none;
}
.legend-toggle.visible {
opacity: 1;
pointer-events: auto;
}
.legend-row {
display: flex;
gap: 16px;
flex-wrap: wrap;
align-items: center;
}
.legend-row-label {
font-size: 18px;
font-weight: 600;
color: var(--text-subtle);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-right: 6px;
min-width: 90px;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
color: var(--text-muted);
font-size: 20px;
}
.legend-dot {
width: 14px;
height: 14px;
border-radius: 50%;
}
.legend-line {
width: 24px;
height: 4px;
border-radius: 2px;
}
.hover-hint {
position: absolute;
top: 14px;
right: 14px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 16px;
font-size: 18px;
color: var(--text-muted);
box-shadow: var(--shadow-card);
pointer-events: none;
}
/* ── Detail Pane ────────────────────────────────────────────────────── */
.detail-pane {
background: var(--surface);
border-left: 1px solid var(--border);
overflow-y: auto;
padding: 0;
}
.detail-empty {
padding: 55px 22px;
text-align: center;
color: var(--text-subtle);
font-size: 13.5px;
}
.detail-section {
padding: 17px 19px;
border-bottom: 1px solid var(--border);
}
.detail-section h3 {
font-size: 11.5px;
font-weight: 600;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 11px;
display: flex;
align-items: center;
gap: 6px;
}
.detail-section h3 .count {
background: var(--surface-soft);
color: var(--text);
padding: 1px 8px;
border-radius: 10px;
font-size: 11px;
letter-spacing: 0;
text-transform: none;
}
.detail-title {
font-size: 20px;
font-weight: 600;
color: var(--text);
margin-bottom: 6px;
line-height: 1.3;
}
.detail-aliases {
color: var(--text-muted);
font-size: 12.5px;
margin-bottom: 8px;
}
.detail-definition {
font-size: 13.5px;
color: var(--text-muted);
line-height: 1.55;
margin-top: 9px;
}
.badge-row {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-top: 11px;
}
.badge {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 3px 10px;
border-radius: 10px;
font-size: 11.5px;
background: var(--surface-soft);
color: var(--text-muted);
border: 1px solid var(--border);
text-decoration: none;
transition: all var(--ease);
}
.badge.link {
background: var(--accent-soft);
color: var(--accent);
border-color: transparent;
cursor: pointer;
}
.badge.link:hover { filter: brightness(1.05); text-decoration: underline; }
.badge.domain { color: #fff; border-color: transparent; }
.noise-badge {
display: inline-flex;
align-items: center;
gap: 3px;
background: rgba(217, 119, 6, 0.12);
color: var(--warning);
padding: 2px 8px;
border-radius: 10px;
font-size: 11px;
font-weight: 500;
margin-left: 6px;
cursor: help;
border: 1px solid rgba(217, 119, 6, 0.25);
}
.item-card {
background: var(--surface-soft);
border-radius: var(--radius-sm);
padding: 11px 13px;
margin-bottom: 9px;
font-size: 13px;
border: 1px solid transparent;
transition: all var(--ease);
}
.item-card:hover { border-color: var(--border); }
.item-card .item-head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 8px;
margin-bottom: 7px;
}
.item-card .predicate {
display: inline-block;
background: var(--accent-soft);
color: var(--accent);
padding: 1px 8px;
border-radius: 3px;
font-size: 11.5px;
font-weight: 500;
}
.item-card .conf {
font-size: 11.5px;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.item-card .triple {
color: var(--text);
font-weight: 500;
margin-bottom: 5px;
}
.item-card .triple .arrow {
color: var(--text-subtle);
margin: 0 5px;
}
.item-card .raw-text {
color: var(--text-muted);
font-size: 12.5px;
line-height: 1.45;
margin-bottom: 7px;
max-height: 80px;
overflow: hidden;
text-overflow: ellipsis;
}
.item-card .evidence-row {
display: flex;
gap: 8px;
font-size: 11.5px;
color: var(--text-muted);
margin-top: 7px;
flex-wrap: wrap;
}
.item-card .evidence-row span {
background: var(--surface);
padding: 1px 7px;
border-radius: 3px;
border: 1px solid var(--border);
}
.item-card .paper-row {
margin-top: 7px;
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.item-card .score-grid {
display: flex;
gap: 11px;
margin-top: 4px;
font-size: 11.5px;
color: var(--text-muted);
font-variant-numeric: tabular-nums;
}
.item-card .score-grid b {
color: var(--text);
font-weight: 500;
}
.path-list {
margin-top: 9px;
border-left: 2px solid var(--border);
padding-left: 11px;
}
.path-step {
font-size: 12.5px;
color: var(--text-muted);
margin-bottom: 4px;
line-height: 1.4;
}
.path-step b { color: var(--text); }
.path-step .rel {
background: var(--surface);
padding: 0 5px;
border-radius: 3px;
font-size: 11px;
color: var(--accent);
border: 1px solid var(--border);
}
.recipe-pill {
display: inline-block;
background: linear-gradient(135deg, #10b981, #3b82f6);
color: #fff;
padding: 1px 8px;
border-radius: 10px;
font-size: 10.5px;
font-weight: 500;
margin-left: 4px;
}
.loading {
padding: 18px;
text-align: center;
color: var(--text-muted);
font-size: 12.5px;
}
.loading .spinner {
display: inline-block;
width: 12px;
height: 12px;
border: 2px solid var(--border);
border-top-color: var(--accent);
border-radius: 50%;
animation: spin 0.8s linear infinite;
margin-right: 6px;
vertical-align: middle;
}
@keyframes spin { to { transform: rotate(360deg); } }
.cold-start-banner {
background: var(--accent-soft);
color: var(--accent);
padding: 11px 18px;
font-size: 13px;
text-align: center;
border-bottom: 1px solid var(--border);
}
.detail-meta-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(140px, 1fr));
gap: 6px;
margin-top: 11px;
}
.detail-meta-grid .kv {
background: var(--surface-soft);
padding: 7px 10px;
border-radius: var(--radius-sm);
font-size: 11.5px;
}
.detail-meta-grid .kv span {
color: var(--text-subtle);
display: block;
text-transform: uppercase;
letter-spacing: 0.03em;
font-size: 10.5px;
margin-bottom: 2px;
}
.detail-meta-grid .kv code {
color: var(--text);
font-size: 11.5px;
word-break: break-all;
}
.tab-row {
display: flex;
gap: 0;
border-bottom: 1px solid var(--border);
background: var(--surface);
position: sticky;
top: 0;
z-index: 2;
}
.tab-btn {
flex: 1;
background: transparent;
border: none;
padding: 11px 12px;
font-size: 12.5px;
color: var(--text-muted);
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all var(--ease);
font-weight: 500;
}
.tab-btn:hover { color: var(--text); }
.tab-btn.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
.tab-btn .count {
background: var(--surface-soft);
padding: 1px 7px;
border-radius: 9px;
font-size: 10.5px;
margin-left: 4px;
}
.recipe-filter-row {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 19px;
background: var(--surface-soft);
font-size: 12.5px;
color: var(--text-muted);
border-bottom: 1px solid var(--border);
}
.recipe-filter-row input { accent-color: var(--accent); cursor: pointer; }
.focus-banner {
padding: 10px 19px;
background: linear-gradient(to right, rgba(10,132,255,0.08), transparent);
border-bottom: 1px solid var(--border);
font-size: 12.5px;
color: var(--text-muted);
display: flex;
align-items: center;
justify-content: space-between;
}
.focus-banner b { color: var(--text); font-weight: 500; }
.focus-banner .close-x {
cursor: pointer;
color: var(--text-subtle);
font-size: 16px;
line-height: 1;
padding: 0 4px;
}
.focus-banner .close-x:hover { color: var(--text); }
/* path finder (collapsible, on-demand) */
.paths-section {
margin: 14px 0 6px 0;
border: 1px solid var(--border);
border-radius: 8px;
background: rgba(255,255,255,0.02);
}
.paths-section[open] { background: rgba(99,102,241,0.04); }
.paths-section > summary {
padding: 10px 14px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
color: var(--text);
list-style: none;
user-select: none;
}
.paths-section > summary::-webkit-details-marker { display: none; }
.paths-section > summary::before { content: "▸ "; color: var(--muted); }
.paths-section[open] > summary::before { content: "▾ "; }
.paths-section > summary:hover { background: rgba(255,255,255,0.04); border-radius: 8px 8px 0 0; }
.paths-body { padding: 8px 14px 14px 14px; }
.paths-controls { display: flex; gap: 8px; margin-bottom: 8px; align-items: center; }
.paths-controls input[type=text] {
flex: 1; min-width: 0;
padding: 7px 10px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 13px;
}
.paths-controls input[type=text]:focus { outline: none; border-color: var(--accent); }
.paths-controls select {
padding: 7px 8px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 6px;
color: var(--text);
font-size: 12px;
}
.paths-controls label.path-checkbox {
display: flex; align-items: center; gap: 4px; font-size: 12px; color: var(--muted);
}
.paths-suggestions { max-height: 200px; overflow-y: auto; margin-bottom: 8px; }
.path-suggest {
padding: 6px 10px;
border-radius: 5px;
cursor: pointer;
font-size: 13px;
color: var(--text);
}
.path-suggest:hover { background: rgba(99,102,241,0.12); }
.path-suggest .domain-tag { margin-left: 6px; font-size: 10px; }
.paths-summary {
font-size: 11px;
color: var(--muted);
margin-bottom: 8px;
padding-bottom: 6px;
border-bottom: 1px solid var(--border);
}
.paths-results { max-height: 360px; overflow-y: auto; }
.paths-empty {
padding: 12px;
text-align: center;
color: var(--muted);
font-size: 13px;
}
.path-row {
padding: 8px 10px;
margin-bottom: 4px;
border-radius: 6px;
background: rgba(255,255,255,0.02);
font-size: 12px;
line-height: 1.7;
word-break: break-word;
}
.path-row:hover { background: rgba(99,102,241,0.08); }
.path-node {
cursor: pointer;
font-weight: 500;
border-bottom: 1px dotted currentColor;
}
.path-node:hover { text-decoration: underline; }
.path-edge {
color: var(--muted);
font-size: 11px;
margin: 0 2px;
}
.path-edge small {
font-family: ui-monospace, SFMono-Regular, monospace;
font-size: 10px;
background: rgba(255,255,255,0.06);
padding: 1px 4px;
border-radius: 3px;
margin: 0 2px;
}
</style>
</head>
<body>
<header class="header">
<div class="header-brand">
<span class="logo-dot"></span>
NeuroClaw
</div>
<div class="header-sub" data-i18n="header_sub">Knowledge Graph Explorer</div>
<div class="stats-line" id="statsLine">
<span class="spinner" style="display:inline-block;width:10px;height:10px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin 0.8s linear infinite;"></span>
<span data-i18n="loading">Loading…</span>
</div>
<button class="icon-btn lang-toggle" id="langBtn" title="Switch language">EN</button>
<button class="icon-btn" id="themeBtn" title="Toggle theme"></button>
</header>
<div id="coldStartBanner" class="cold-start-banner" style="display:none;" data-i18n="cold_start">
First load reads the 180MB knowledge graph (takes ~30–60s)…
</div>
<div class="layout">
<aside class="sidebar">
<div class="search-box">
<input type="text" class="search-input" id="searchInput"
data-i18n-ph="search_placeholder"
placeholder="Search biomarker / outcome / concept (≥2 chars)" autocomplete="off">
<select class="domain-select" id="atomSelect" style="width:100%;margin-top:6px;padding:4px 6px;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--surface);color:var(--text);font-size:12.5px;">
<option value="" data-i18n="atom_all">All</option>
<option value="disease">Disease</option>
<option value="drug">Drug</option>
<option value="imaging_marker">Imaging marker</option>
<option value="gene_target">Gene / target</option>
<option value="cognitive_task">Cognitive task</option>
<option value="outcome">Outcome</option>
<option value="individual_data">Individual data</option>
</select>
<div class="task-row" style="display:flex;gap:6px;align-items:center;margin-top:8px;flex-wrap:wrap;">
<select id="taskSelect" style="flex:1;min-width:160px;padding:4px 6px;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--surface);color:var(--text);font-size:12.5px;">
<option value="">— Task / Chain —</option>
</select>
<label style="display:flex;align-items:center;gap:4px;font-size:12px;color:var(--muted);">
<input type="checkbox" id="strictChainToggle" disabled> strict chain
</label>
</div>
</div>
<div class="results" id="results">
<div class="results-hint" data-i18n="search_hint">Enter a keyword to search concepts</div>
</div>
</aside>
<main class="graph-pane">
<div class="graph-controls">
<button class="icon-btn back-btn" id="backBtn" disabled title="Back to previous node">
<span style="font-size:15px;line-height:1;"></span>
<span data-i18n="back">Back</span>
</button>
<label><span data-i18n="depth">Depth</span>
<select id="depthSelect">
<option value="1" selected>1</option>
<option value="2">2</option>
</select>
</label>
<label><span data-i18n="neighbors">Neighbors</span>
<input type="number" id="limitInput" value="30" min="5" max="200" step="5"
style="width:64px;padding:3px 6px;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--surface);color:var(--text);font-size:inherit;">
</label>
<label><span data-i18n="edge_type">Edge type</span>
<select id="edgeTypeSelect">
<option value="all" selected data-i18n="all">All</option>
<option value="is_biomarker_of">is_biomarker_of</option>
<option value="correlates_with">correlates_with</option>
<option value="predicts">predicts</option>
<option value="causes">causes</option>
<option value="associated_with,is_associated_with">associated_with</option>
<option value="treats">treats</option>
<option value="modulates">modulates</option>
<option value="reduces,increases">reduces / increases</option>
</select>
</label>
<label><span data-i18n="font">Font</span>
<select id="fontSelect">
<option value="22">S</option>
<option value="26">M</option>
<option value="32" selected>L</option>
<option value="38">XL</option>
<option value="44">XXL</option>
</select>
</label>
<label><span>Layout</span>
<select id="layoutSelect">
<option value="ring">Ring</option>
<option value="force" selected>Force</option>
</select>
</label>
<span class="graph-info" id="graphInfo" data-i18n="hint_select">Select a node to start exploring</span>
</div>
<div class="graph-canvas">
<div id="sigma-container"></div>
<div class="graph-empty" id="graphEmpty">
<div>
<div style="font-size:34px;margin-bottom:11px;opacity:0.3;"></div>
<div data-i18n="empty_main">Search a concept in the left panel and click it</div>
<div style="font-size:12px;margin-top:7px;opacity:0.7;" data-i18n="empty_sub">
Hover to reveal edges · double-click to change query · wheel to zoom
</div>
</div>
</div>
<div class="hover-hint" id="hoverHint" style="display:none;"></div>
<div class="legend" id="legend" style="display:none;"></div>
<div class="legend-toggle" id="legendToggle">Legend ▲</div>
</div>
</main>
<aside class="detail-pane" id="detailPane">
<div class="detail-empty">
<div style="font-size:30px;margin-bottom:11px;opacity:0.3;"></div>
<div data-i18n="detail_empty_title">No node selected</div>
<span style="font-size:12.5px;" data-i18n="detail_empty_sub">Click a result or a node to view details</span>
</div>
</aside>
</div>
<script>
// ── State ─────────────────────────────────────────────────────────────
const state = {
currentNode: null,
currentNodeAtoms: [], // atoms of the current query node (for task-availability)
taskPaths: [], // current task-paths response: full per-path node lists
lockedPaths: null, // Set<pid> — when set, hover is overridden by this lock
focusEdge: null, // {source, target} — a single-click edge inspection
atomFilter: "",
depth: 1,
limit: 30,
edgeTypes: "all",
sigma: null,
graph: null,
layoutWorker: null,
kgLoaded: false,
hasRecipes: false,
recipeOnly: false,
activeTab: "hypotheses",
quality: "clean",
hoveredNode: null,
pinnedNode: null, // persistent single-click selection (non-query)
showAllInitial: false, // 2s preview: all colors+labels before dimming
lang: "en",
fontSize: 32,
layoutMode: "force", // "ring" = concentric rings, "force" = FA2 random
cachedNeighborhood: null,
history: [], // stack of previous node IDs for Back navigation
transitioning: false, // guard to prevent overlapping zoom animations
};
const ATOM_COLORS = {
disease: "#ef4444",
drug: "#ec4899",
imaging_marker: "#3b82f6",
gene_target: "#f59e0b",
cognitive_task: "#a855f7",
outcome: "#84cc16",
individual_data: "#14b8a6",
};
const ATOM_SHORT = {
disease: "D", drug: "Rx", imaging_marker: "IM", gene_target: "G",
cognitive_task: "Tk", outcome: "O", individual_data: "Idv",
};
const ATOM_FULL = {
disease: "Disease",
drug: "Drug",
imaging_marker: "Imaging marker",
gene_target: "Gene / target",
cognitive_task: "Cognitive task",
outcome: "Outcome",
individual_data: "Individual data",
};
function atomBadges(atoms) {
if (!atoms || !atoms.length) return "";
return atoms.map((a) =>
`<span class="domain-tag" title="${a}" style="background:${ATOM_COLORS[a] || "#94a3b8"};">${ATOM_SHORT[a] || a}</span>`
).join("");
}
const GRAY = "#d1d5db";
const GRAY_DARK = "#64748b";
const DIM_EDGE = "#e5e7eb";
// ── i18n ─────────────────────────────────────────────────────────────
const I18N = {
en: {
header_sub: "Knowledge Graph Explorer",
loading: "Loading…",
cold_start: "First load reads the 180MB knowledge graph (takes ~30–60s)…",
search_placeholder: "Search biomarker / outcome / concept (≥2 chars)",
search_hint: "Enter a keyword to search concepts",
atom_all: "All",
back: "Back",
depth: "Depth",
neighbors: "Neighbors",
edge_type: "Edge type",
font: "Font",
all: "All",
hint_select: "Select a node to start exploring",
empty_main: "Search a concept in the left panel and click it",
empty_sub: "Hover to reveal edges · double-click to change query · wheel to zoom",
detail_empty_title: "No node selected",
detail_empty_sub: "Click a result or a node to view details",
stats_concepts: "concepts",
stats_edges: "edges",
stats_claims: "verified claims",
stats_hyps: "new hypotheses",
stats_recipes: "with recipe",
tab_hypotheses: "Hypotheses",
tab_claims: "Verified Claims",
tab_meta: "Metadata",
filter_recipe: "Show only recipe-matched hypotheses",
no_hypotheses: "No hypotheses for this concept",
no_claims: "No verified claims for this concept",
no_match: "No match found",
external_ids: "External IDs",
no_external: "No external IDs",
source: "Source vocab",
semantic_types: "Semantic types",
atlas_mapping: "Atlas mapping",
aliases: "Aliases",
nodes_edges: (n, e, t) => `${n} nodes · ${e} edges${t ? " · sampled" : ""}`,
sampling: "sampled",
low_quality: "⚠ low quality",
hover_show: "Hover a node to reveal colors and relationships",
preview_all: "Showing all colors · will dim in 2s",
hover_all: "Hovering query node · showing all colors",
hover_explain: (l) => `Hovering: ${l}`,
showing_edge: "Sources for this relationship",
between: "between",
and: "and",
close: "×",
no_sources: "No direct sources linking these two concepts",
curated_edges: "Curated edges",
supporting_claims: "Supporting claims",
pubmed: "PMID",
doi: "DOI",
year: "Year",
composite: "composite",
novelty: "novelty",
evidence: "evidence",
testability: "testability",
critic: "critic",
predicate_filter: (p) => `Filter: predicate = ${p}`,
node_type: "Node type",
edge_type_legend: "Edge type",
more_steps: (n) => `+ ${n} more steps`,
paths_section_title: "Find paths to another node",
paths_target_placeholder: "Type to search target node…",
paths_max_hops: "hops",
paths_searching: "Searching paths…",
paths_no_results: "No paths found within these limits. Try increasing hops.",
paths_truncated: (reason) => `truncated: ${reason}`,
paths_summary_text: (shown, total, plus) => `Showing ${shown} of ${total}${plus ? "+" : ""} paths`,
paths_reason_count_cap: "limit reached",
paths_reason_timeout: "timed out",
paths_reason_max_paths: "display limit",
load_failed: "load failed",
},
zh: {
header_sub: "知识图谱浏览器",
loading: "载入中…",
cold_start: "首次打开需要加载 180MB 知识图谱(约 30–60 秒)…",
search_placeholder: "搜索 biomarker / outcome / 概念(≥2 字符)",
search_hint: "输入关键词,快速检索概念",
atom_all: "全部",
back: "返回",
depth: "深度",
neighbors: "邻域",
edge_type: "边类型",
font: "字号",
all: "全部",
hint_select: "选中节点开始探索",
empty_main: "在左侧搜索并点击一个概念",
empty_sub: "悬浮显示边 · 双击切换 query · 滚轮缩放",
detail_empty_title: "未选中节点",
detail_empty_sub: "点击搜索结果或图中节点查看详情",
stats_concepts: "概念",
stats_edges: "边",
stats_claims: "已证实 Claims",
stats_hyps: "新假设",
stats_recipes: "带 recipe",
tab_hypotheses: "新假设",
tab_claims: "已证实 Claims",
tab_meta: "元数据",
filter_recipe: "仅显示已匹配 recipe 的假设",
no_hypotheses: "此概念暂无新假设",
no_claims: "此概念暂无已证实的 Claims",
no_match: "未找到匹配",
external_ids: "外部 ID",
no_external: "无外部 ID",
source: "来源词表",
semantic_types: "语义类型",
atlas_mapping: "Atlas 映射",
aliases: "别名",
nodes_edges: (n, e, t) => `${n} 节点 · ${e}${t ? " · 已采样" : ""}`,
sampling: "已采样",
low_quality: "⚠ 低质量",
hover_show: "悬浮节点可显示颜色与关系",
preview_all: "正显示全部颜色 · 2 秒后变灰",
hover_all: "正悬浮于 query 节点 · 显示全部颜色",
hover_explain: (l) => `悬浮:${l}`,
showing_edge: "这两个概念之间关系的来源",
between: "",
and: " 与 ",
close: "×",
no_sources: "这两个概念没有直接关联来源",
curated_edges: "精选边",
supporting_claims: "证据 Claims",
pubmed: "PMID",
doi: "DOI",
year: "年份",
composite: "综合",
novelty: "新颖性",
evidence: "证据",
testability: "可测试性",
critic: "评审",
predicate_filter: (p) => `过滤:谓词 = ${p}`,
node_type: "节点类型",
edge_type_legend: "关系类型",
more_steps: (n) => `+ 还有 ${n} 步`,
paths_section_title: "寻找到另一节点的路径",
paths_target_placeholder: "输入目标节点名进行搜索…",
paths_max_hops: "跳",
paths_searching: "正在搜索路径…",
paths_no_results: "在限制范围内未找到路径,可尝试增加跳数",
paths_truncated: (reason) => `已截断:${reason}`,
paths_summary_text: (shown, total, plus) => `共 ${total}${plus ? "+" : ""} 条路径,显示前 ${shown} 条`,
paths_reason_count_cap: "达到计数上限",
paths_reason_timeout: "搜索超时",
paths_reason_max_paths: "达到显示上限",
load_failed: "加载失败",
},
};
const t = (key, ...args) => {
const v = I18N[state.lang]?.[key] ?? I18N.en[key] ?? key;
return typeof v === "function" ? v(...args) : v;
};
// ── DOM helpers ──────────────────────────────────────────────────────
const $ = (id) => document.getElementById(id);
const el = (tag, attrs = {}, children = []) => {
const node = document.createElement(tag);
Object.entries(attrs).forEach(([k, v]) => {
if (k === "className") node.className = v;
else if (k === "textContent") node.textContent = v;
else if (k === "html") node.innerHTML = v;
else if (k.startsWith("on")) node.addEventListener(k.slice(2), v);
else node.setAttribute(k, v);
});
(Array.isArray(children) ? children : [children]).forEach((c) => {
if (c == null) return;
if (typeof c === "string") node.appendChild(document.createTextNode(c));
else node.appendChild(c);
});
return node;
};
const escapeHtml = (s) => (s || "").replace(/[&<>"']/g, (c) => ({
"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"
}[c]));
const fmt = (n, digits = 2) => (n == null || isNaN(n)) ? "—" : Number(n).toFixed(digits);
function mixHex(a, b, ratio) {
const pa = a.startsWith("#") ? a.slice(1) : a;
const pb = b.startsWith("#") ? b.slice(1) : b;
const ar = parseInt(pa.slice(0, 2), 16), ag = parseInt(pa.slice(2, 4), 16), ab = parseInt(pa.slice(4, 6), 16);
const br = parseInt(pb.slice(0, 2), 16), bg = parseInt(pb.slice(2, 4), 16), bb = parseInt(pb.slice(4, 6), 16);
const r = Math.round(ar + (br - ar) * ratio);
const g = Math.round(ag + (bg - ag) * ratio);
const bl = Math.round(ab + (bb - ab) * ratio);
return "#" + [r, g, bl].map((v) => v.toString(16).padStart(2, "0")).join("");
}
// ── Physics-style easings ────────────────────────────────────────────
// Silky out: strong initial velocity, very long gentle tail. No overshoot.
const easeOutQuint = (t) => 1 - Math.pow(1 - t, 5);
// Smooth in-out for bidirectional transitions (both ends decelerate).
const easeInOutQuint = (t) =>
t < 0.5 ? 16 * t * t * t * t * t : 1 - Math.pow(-2 * t + 2, 5) / 2;
// Cubic in-out kept for very short pulls (Back phase 1).
const easeInOutCubic = (t) =>
t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
// Animate the sigma camera with a custom easing. Returns a Promise.
function animateCamera(target, { duration = 700, easing = easeInOutQuint } = {}) {
return new Promise((resolve) => {
if (!state.sigma) { resolve(); return; }
const camera = state.sigma.getCamera();
const start = { x: camera.x, y: camera.y, ratio: camera.ratio, angle: camera.angle || 0 };
const end = {
x: target.x != null ? target.x : start.x,
y: target.y != null ? target.y : start.y,
ratio: target.ratio != null ? target.ratio : start.ratio,
angle: start.angle,
};
const t0 = performance.now();
function frame(now) {
const t = Math.min(1, (now - t0) / duration);
const e = easing(t);
camera.setState({
x: start.x + (end.x - start.x) * e,
y: start.y + (end.y - start.y) * e,
ratio: start.ratio + (end.ratio - start.ratio) * e,
angle: start.angle,
});
if (t < 1) requestAnimationFrame(frame);
else resolve();
}
requestAnimationFrame(frame);
});
}
// Animate the camera toward a SPECIFIC node, re-reading the node's display
// coordinates every frame. Sigma's camera uses a bbox-normalized coord
// system, not graph coords — and while FA2 is still running the bbox keeps
// drifting, so the display position of the node (even a pinned one) moves.
// Re-reading per-frame guarantees we actually land on the node at the end.
function animateCameraToNode(nodeId, { duration = 900, easing = easeInOutQuint, startRatio, endRatio = 1.0 } = {}) {
return new Promise((resolve) => {
if (!state.sigma || !state.graph || !state.graph.hasNode(nodeId)) { resolve(); return; }
const camera = state.sigma.getCamera();
const startState = { x: camera.x, y: camera.y, ratio: camera.ratio };
const fromRatio = startRatio != null ? startRatio : startState.ratio;
if (startRatio != null) camera.setState({ x: startState.x, y: startState.y, ratio: fromRatio, angle: 0 });
const t0 = performance.now();
function frame(now) {
const t = Math.min(1, (now - t0) / duration);
const e = easing(t);
// Read the node's CURRENT display coords — these drift as FA2 resizes bbox.
let tx = 0, ty = 0;
try {
const d = state.sigma.getNodeDisplayData(nodeId);
if (d) { tx = d.x; ty = d.y; }
} catch (err) {}
camera.setState({
x: startState.x + (tx - startState.x) * e,
y: startState.y + (ty - startState.y) * e,
ratio: fromRatio + (endRatio - fromRatio) * e,
angle: 0,
});
if (t < 1) requestAnimationFrame(frame);
else resolve();
}
requestAnimationFrame(frame);
});
}
// Snap camera dead-center on a node, using sigma's current bbox normalization.
function centerCameraOn(nodeId, ratio) {
if (!state.sigma || !state.graph || !state.graph.hasNode(nodeId)) return;
try {
const d = state.sigma.getNodeDisplayData(nodeId);
if (!d) return;
const cam = state.sigma.getCamera();
const st = cam.getState();
cam.setState({
x: d.x,
y: d.y,
ratio: ratio != null ? ratio : st.ratio,
angle: 0,
});
} catch (e) {}
}
// ── Theme ────────────────────────────────────────────────────────────
(function initTheme() {
const saved = localStorage.getItem("neuroclaw-theme");
const theme = saved || (window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
document.documentElement.dataset.theme = theme;
$("themeBtn").textContent = theme === "dark" ? "☀" : "☾";
})();
$("themeBtn").addEventListener("click", () => {
const cur = document.documentElement.dataset.theme === "dark" ? "light" : "dark";
document.documentElement.dataset.theme = cur;
localStorage.setItem("neuroclaw-theme", cur);
$("themeBtn").textContent = cur === "dark" ? "☀" : "☾";
// re-render graph label colors for theme change
if (state.sigma) state.sigma.refresh();
});
// ── Language ─────────────────────────────────────────────────────────
(function initLang() {
const saved = localStorage.getItem("neuroclaw-lang");
state.lang = saved === "zh" ? "zh" : "en";
document.documentElement.lang = state.lang === "zh" ? "zh-CN" : "en";
applyI18n();
$("langBtn").textContent = state.lang === "zh" ? "中" : "EN";
})();
function applyI18n() {
document.querySelectorAll("[data-i18n]").forEach((n) => {
const key = n.getAttribute("data-i18n");
const v = t(key);
if (typeof v === "string") n.textContent = v;
});
document.querySelectorAll("[data-i18n-ph]").forEach((n) => {
const key = n.getAttribute("data-i18n-ph");
const v = t(key);
if (typeof v === "string") n.setAttribute("placeholder", v);
});
document.querySelectorAll("[data-i18n-title]").forEach((n) => {
const key = n.getAttribute("data-i18n-title");
const v = t(key);
if (typeof v === "string") n.setAttribute("title", v);
});
}
$("langBtn").addEventListener("click", () => {
state.lang = state.lang === "en" ? "zh" : "en";
localStorage.setItem("neuroclaw-lang", state.lang);
document.documentElement.lang = state.lang === "zh" ? "zh-CN" : "en";
$("langBtn").textContent = state.lang === "zh" ? "中" : "EN";
applyI18n();
// re-render detail pane and stats with new lang
pollStatsRender();
if (state.currentNode) {
if (state.focusEdge) loadEdgeSources(state.focusEdge.source, state.focusEdge.target);
else loadDetail(state.currentNode);
}
});
// ── Font-size control ────────────────────────────────────────────────
(function initFont() {
const saved = parseInt(localStorage.getItem("neuroclaw-font") || "18", 10);
if (!isNaN(saved)) {
state.fontSize = saved;
$("fontSelect").value = String(saved);
applyFontSize(saved);
}
})();
function applyFontSize(px) {
state.fontSize = px;
document.documentElement.style.setProperty("--ui-font-size", px + "px");
document.documentElement.style.setProperty("--graph-label-size", (px + 0) + "px");
localStorage.setItem("neuroclaw-font", String(px));
if (state.sigma) {
state.sigma.setSetting("labelSize", px);
state.sigma.refresh();
}
}
$("fontSelect").addEventListener("change", (e) => {
applyFontSize(parseInt(e.target.value, 10));
});
// ── KG status polling ────────────────────────────────────────────────
let _lastStats = null;
async function pollStats() {
try {
const r = await fetch("/api/kg/stats").then((r) => r.json());
if (r.loaded) {
const wasLoaded = state.kgLoaded;
state.kgLoaded = true;
_lastStats = r;
$("coldStartBanner").style.display = "none";
pollStatsRender();
state.hasRecipes = (r.n_recipes || 0) > 0;
if (!wasLoaded) {
// First transition to loaded: show default top list
loadDefaultList();
}
return;
}
$("coldStartBanner").style.display = "block";
if (!r.loading) {
fetch("/api/kg/load", { method: "POST" }).catch(() => {});
}
setTimeout(pollStats, 2500);
} catch (e) {
$("statsLine").innerHTML = '<span style="color:var(--danger);">Load failed</span>';
}
}
function pollStatsRender() {
if (!_lastStats) return;
const r = _lastStats;
$("statsLine").innerHTML = `
<span>${(r.n_concepts||0).toLocaleString()} ${t("stats_concepts")}</span>
<span class="stats-dot"></span>
<span>${(r.n_edges||0).toLocaleString()} ${t("stats_edges")}</span>
<span class="stats-dot"></span>
<span>${(r.n_claims||0).toLocaleString()} ${t("stats_claims")}</span>
<span class="stats-dot"></span>
<span>${(r.n_hypotheses||0).toLocaleString()} ${t("stats_hyps")}</span>
${r.n_recipes ? `<span class="stats-dot"></span><span>${r.n_recipes} ${t("stats_recipes")}</span>` : ""}
`;
}
pollStats();
// ── Search ───────────────────────────────────────────────────────────
let searchTimer = null;
$("searchInput").addEventListener("input", (e) => {
clearTimeout(searchTimer);
const q = e.target.value.trim();
if (q.length < 2) {
// Empty/short query: load the domain-scoped default list
loadDefaultList();
return;
}
searchTimer = setTimeout(() => runSearch(q), 300);
});
$("atomSelect").addEventListener("change", (e) => {
state.atomFilter = e.target.value || "";
const q = $("searchInput").value.trim();
if (q.length >= 2) runSearch(q);
else loadDefaultList();
});
async function loadDefaultList() {
if (!state.kgLoaded) return;
const container = $("results");
container.innerHTML = `<div class="loading"><span class="spinner"></span>${t("loading")}</div>`;
try {
const url = new URL("/api/kg/search", location.origin);
url.searchParams.set("q", "");
if (state.atomFilter) url.searchParams.set("atom", state.atomFilter);
url.searchParams.set("quality", state.quality);
url.searchParams.set("limit", "50");
const r = await fetch(url).then((r) => r.json());
renderResults(r.results || []);
} catch (e) {
container.innerHTML = `<div class="results-hint" style="color:var(--danger);">load failed</div>`;
}
}
async function runSearch(q) {
if (!state.kgLoaded) {
$("results").innerHTML = `<div class="loading"><span class="spinner"></span>${t("loading")}</div>`;
return;
}
$("results").innerHTML = `<div class="loading"><span class="spinner"></span>${t("loading")}</div>`;
try {
const url = new URL("/api/kg/search", location.origin);
url.searchParams.set("q", q);
if (state.atomFilter) url.searchParams.set("atom", state.atomFilter);
url.searchParams.set("quality", state.quality);
url.searchParams.set("limit", "30");
const r = await fetch(url).then((r) => r.json());
renderResults(r.results || []);
} catch (e) {
$("results").innerHTML = `<div class="results-hint" style="color:var(--danger);">${t("loading")}</div>`;
}
}
function renderResults(items) {
const container = $("results");
container.innerHTML = "";
if (!items.length) {
container.innerHTML = `<div class="results-hint">${t("no_match")}</div>`;
return;
}
items.forEach((it) => {
const noiseMark = it.is_noise ? '<span class="noise-mini" title="possible noise"></span>' : "";
const node = el("div", {
className: "result-item" + (it.is_noise ? " is-noise" : ""),
"data-id": it.id,
}, [
el("div", { className: "result-name", html: escapeHtml(it.name) + noiseMark }),
el("div", { className: "result-meta", html: `
${atomBadges(it.atoms)}
<span class="badge">${(it.n_claims || 0).toLocaleString()} claims</span>
<span class="badge">${it.n_hypotheses || 0} hyps</span>
`}),
]);
node.addEventListener("click", () => selectNode(it.id));
container.appendChild(node);
});
}
// ── Node selection (from results list) ───────────────────────────────
async function selectNode(nodeId, opts = {}) {
const { pushHistory = true, animateIn = false } = opts;
if (pushHistory && state.currentNode && state.currentNode !== nodeId) {
state.history.push(state.currentNode);
updateBackBtn();
}
state.currentNode = nodeId;
state.focusEdge = null;
state.pinnedNode = null;
[...document.querySelectorAll(".result-item")].forEach((r) => {
r.classList.toggle("active", r.dataset.id === nodeId);
});
await Promise.all([loadNeighborhood(nodeId), loadDetail(nodeId)]);
if (animateIn && state.sigma && state.graph && state.graph.hasNode(nodeId)) {
// Fresh render: start slightly zoomed-in centered on the node, ease out.
// animateCameraToNode re-reads display coords each frame so bbox drift
// (FA2 still running) can't pull us off-target.
await animateCameraToNode(nodeId, {
duration: 1000,
easing: easeOutQuint,
startRatio: 0.6,
endRatio: 1.0,
});
centerCameraOn(nodeId, 1.0);
state.sigma.refresh();
}
}
function updateBackBtn() {
const btn = $("backBtn");
if (!btn) return;
btn.disabled = state.history.length === 0;
}
// Dive into a node: zoom camera onto it smoothly, then swap graph and settle.
async function navigateIntoNode(nodeId) {
if (!state.sigma || !state.graph || !state.graph.hasNode(nodeId) || state.transitioning) return;
state.transitioning = true;
try {
// Phase 1 — glide the camera onto the clicked node (in display coords
// of the CURRENT graph). Re-read each frame so FA2 drift is tracked.
await animateCameraToNode(nodeId, {
duration: 900,
easing: easeInOutQuint,
endRatio: 0.28,
});
// Phase 2 — replace the graph rooted at that node (new sigma instance).
await selectNode(nodeId, { pushHistory: true, animateIn: false });
// Phase 3 — settle out to ratio 1.0 while keeping the camera glued to
// the new center node. centerCameraOn + animateCameraToNode read the
// node's display coords fresh every frame, so bbox drift in the new
// FA2 run can't pull us to a corner.
if (state.sigma) {
centerCameraOn(nodeId, 0.42);
await animateCameraToNode(nodeId, {
duration: 1100,
easing: easeOutQuint,
endRatio: 1.0,
});
centerCameraOn(nodeId, 1.0);
state.sigma.refresh();
}
} finally {
state.transitioning = false;
}
}
// Back to previous node: zoom out first, then swap graph and settle.
async function navigateBack() {
if (state.history.length === 0 || state.transitioning) return;
const prevId = state.history.pop();
updateBackBtn();
state.transitioning = true;
try {
if (state.sigma) {
// Phase 1 — pull back with a smooth in-out.
await animateCamera({ ratio: 2.0 }, { duration: 520, easing: easeInOutCubic });
}
// Phase 2 — load previous neighborhood (no history push; we're going back).
await selectNode(prevId, { pushHistory: false, animateIn: false });
// Phase 3 — settle from a wide view down to 1.0, centered on the prev node.
if (state.sigma) {
centerCameraOn(prevId, 1.8);
await animateCameraToNode(prevId, {
duration: 1000,
easing: easeOutQuint,
endRatio: 1.0,
});
centerCameraOn(prevId, 1.0);
state.sigma.refresh();
}
} finally {
state.transitioning = false;
}
}
$("backBtn").addEventListener("click", () => navigateBack());
// ── Task / chain subgraph ────────────────────────────────────────────
//
// Behaviour: the dropdown options remain in the DOM at all times, but each
// option is enabled only when the currently-selected query node has an atom
// that intersects the task/chain atom set. With no query node selected, every
// real option is greyed out — the user sees what's available but can't pick.
const taskState = { tasks: [], chains: [] };
function _atomSetFor(opt) {
// Stored as comma-separated atom values on data-atoms.
return new Set((opt.dataset.atoms || "").split(",").filter(Boolean));
}
function refreshTaskOptionAvailability() {
const sel = $("taskSelect");
if (!sel) return;
const queryAtoms = new Set(state.currentNodeAtoms || []);
const hasQuery = !!state.currentNode && queryAtoms.size > 0;
let enabledCount = 0;
[...sel.options].forEach((opt) => {
if (!opt.value) { opt.disabled = false; return; } // placeholder
const atomSet = _atomSetFor(opt);
const hit = hasQuery && [...queryAtoms].some((a) => atomSet.has(a));
opt.disabled = !hit;
if (hit) enabledCount += 1;
});
// If the currently-selected option became disabled, reset.
if (sel.selectedOptions[0] && sel.selectedOptions[0].disabled) {
sel.value = "";
$("strictChainToggle").disabled = true;
$("strictChainToggle").checked = false;
}
// Update placeholder hint.
const placeholder = sel.options[0];
if (placeholder && !placeholder.value) {
placeholder.textContent = hasQuery
? (enabledCount ? "— Task / Chain —" : "— No task covers this node —")
: "— Select a query node first —";
}
}
async function loadTasks() {
try {
const r = await fetch("/api/kg/tasks").then((r) => r.json());
const sel = $("taskSelect");
if (!sel || !r) return;
taskState.tasks = r.tasks || [];
taskState.chains = r.chains || [];
const optHtml = (label, items, kind) => items.length
? `<optgroup label="${label}">` + items.map((it) => {
const atoms = (kind === "chain")
? (it.chain || [])
: ([...(it.inputs || []), it.output].filter(Boolean));
const atomAttr = [...new Set(atoms)].join(",");
return `<option value="${escapeHtml(it.name)}" data-kind="${kind}" data-atoms="${escapeHtml(atomAttr)}">${escapeHtml(it.name)}</option>`;
}).join("") + `</optgroup>`
: "";
sel.innerHTML = `<option value="">— Select a query node first —</option>` +
optHtml("Chains (≥3-hop)", taskState.chains, "chain") +
optHtml("Tasks", taskState.tasks, "task");
refreshTaskOptionAvailability();
} catch (e) {}
}
loadTasks();
$("taskSelect").addEventListener("change", async (e) => {
const opt = e.target.selectedOptions[0];
const name = opt && opt.value;
const kind = (opt && opt.dataset.kind) || "task";
const strictBox = $("strictChainToggle");
strictBox.disabled = !(name && kind === "chain");
if (!name) { strictBox.checked = false; return; }
if (!state.currentNode) {
e.target.value = "";
return;
}
await loadTaskSubgraph(name, kind, strictBox.checked);
});
$("strictChainToggle").addEventListener("change", () => {
const sel = $("taskSelect");
const opt = sel.selectedOptions[0];
if (!opt || !opt.value) return;
loadTaskSubgraph(opt.value, opt.dataset.kind || "task", $("strictChainToggle").checked);
});
async function loadTaskSubgraph(name, kind, strict) {
$("graphInfo").textContent = t("loading") + "…";
if (!state.currentNode) {
$("graphInfo").textContent = "select a query node first";
return;
}
try {
const url = new URL("/api/kg/task-paths", location.origin);
url.searchParams.set("task", name);
url.searchParams.set("kind", kind);
url.searchParams.set("anchor", state.currentNode);
url.searchParams.set("limit", "30");
const r = await fetch(url).then((r) => r.json());
if (r.error) { $("graphInfo").textContent = r.error; return; }
renderTaskGraph(r);
} catch (err) {
$("graphInfo").textContent = "task subgraph load failed";
}
}
function renderTaskGraph(data) {
$("graphEmpty").style.display = "none";
$("legend").style.display = "flex";
$("hoverHint").style.display = "block";
state.showAllInitial = true;
const nodes = data.nodes || [];
const edges = data.edges || [];
const nPaths = data.n_paths != null ? data.n_paths : (data.paths || []).length;
const seqStr = (data.atom_sequence || []).join(" → ");
$("graphInfo").textContent = `${data.kind}: ${data.task} · ${nPaths} paths through anchor` +
(seqStr ? ` · ${seqStr}` : "") + ` · ${nodes.length} nodes / ${edges.length} edges`;
state.history = []; updateBackBtn();
state.currentNode = data.anchor || state.currentNode || null;
state.focusEdge = null; state.pinnedNode = null;
state.taskPaths = data.paths || [];
state.lockedPaths = null;
if (state.layoutWorker) { try { state.layoutWorker.kill(); } catch (e) {} state.layoutWorker = null; }
if (state.sigma) { state.sigma.kill(); state.sigma = null; }
const Graph = graphology.Graph;
const graph = new Graph({ type: "directed", multi: false, allowSelfLoops: false });
nodes.forEach((n, i) => {
const ang = (i / Math.max(1, nodes.length)) * 2 * Math.PI;
const r = 1.5 + Math.random() * 1.5;
const isAnchor = !!n.is_anchor;
graph.addNode(n.id, {
label: n.label, originalLabel: n.label,
size: isAnchor ? 22 : 14, baseSize: isAnchor ? 22 : 14,
color: n.color, baseColor: n.color,
x: isAnchor ? 0 : Math.cos(ang) * r, y: isAnchor ? 0 : Math.sin(ang) * r,
domain: n.primary_atom, is_center: isAnchor, is_claim: false,
is_noise: !!n.is_noise, depth: isAnchor ? 0 : 1,
paths: n.path_ids || [],
});
});
edges.forEach((e) => {
if (!graph.hasNode(e.source) || !graph.hasNode(e.target)) return;
if (graph.hasEdge(e.source, e.target)) return;
graph.addEdge(e.source, e.target, {
label: e.label, color: e.color, baseColor: e.color, size: 1.5,
relation: e.label, relations_fwd: [e.label], relations_rev: [],
bidirectional: false, curvature: 0.2,
paths: e.path_ids || [],
});
});
state.graph = graph;
const container = $("sigma-container");
const curveProgram = Sigma.rendering && Sigma.rendering.EdgeCurveProgram;
const edgeSettings = curveProgram
? { defaultEdgeType: "curved", edgeProgramClasses: { curved: curveProgram } }
: { defaultEdgeType: "arrow" };
// Compute the active path-set: locked > hovered. When set, every render
// step (node/edge reducer) consults this to decide what's lit.
function activePathSet() {
if (state.lockedPaths && state.lockedPaths.size) return state.lockedPaths;
const hover = state.hoveredNode;
if (!hover || !graph.hasNode(hover)) return null;
const pids = graph.getNodeAttribute(hover, "paths") || [];
return new Set(pids);
}
state.sigma = new Sigma(graph, container, {
...edgeSettings,
padding: 100, renderEdgeLabels: false,
labelSize: state.fontSize, labelWeight: "600",
labelRenderedSizeThreshold: 4,
nodeReducer(nid, d) {
const res = { ...d };
if (state.showAllInitial) { res.color = d.baseColor; res.label = d.originalLabel; res.forceLabel = true; return res; }
// Anchor always lit and labelled.
if (d.is_center) {
res.color = d.baseColor; res.label = d.originalLabel;
res.forceLabel = true;
res.size = d.baseSize * (nid === state.hoveredNode ? 1.1 : 1.0);
return res;
}
const activeSet = activePathSet();
if (!activeSet) {
// Idle: keep nodes coloured, hide labels to keep the bundle readable.
res.color = d.baseColor;
res.label = "";
res.forceLabel = false;
return res;
}
const onPath = (d.paths || []).some((pid) => activeSet.has(pid));
if (onPath) {
res.color = d.baseColor;
res.label = d.originalLabel;
res.forceLabel = true;
if (nid === state.hoveredNode) res.size = d.baseSize * 1.2;
} else {
res.color = GRAY; res.label = ""; res.forceLabel = false;
}
return res;
},
edgeReducer(eid, d) {
const res = { ...d };
if (state.showAllInitial) { res.color = d.baseColor; res.label = d.relation; res.size = 2; return res; }
const activeSet = activePathSet();
if (!activeSet) {
res.color = d.baseColor; res.label = ""; res.size = 1.5;
return res;
}
const onPath = (d.paths || []).some((pid) => activeSet.has(pid));
if (onPath) { res.color = d.baseColor; res.label = d.relation; res.size = 2.5; }
else { res.color = DIM_EDGE; res.label = ""; res.size = 1; }
return res;
},
});
try {
const settings = graphologyLibrary.layoutForceAtlas2.inferSettings(graph);
state.layoutWorker = new graphologyLibrary.FA2Layout(graph, { settings });
state.layoutWorker.start();
setTimeout(() => { try { state.layoutWorker && state.layoutWorker.stop(); } catch (e) {} }, 2500);
} catch (e) {}
state.sigma.on("enterNode", ({ node }) => { state.hoveredNode = node; state.sigma.refresh(); });
state.sigma.on("leaveNode", () => { state.hoveredNode = null; state.sigma.refresh(); });
// Click vs double-click: detect double via 260ms timer.
// - single click: lock the chains containing this node + load claims
// for every adjacent edge across those chains.
// - double click: switch query node to this one + reset task dropdown.
let taskSingleClickTimer = null;
state.sigma.on("clickNode", ({ node }) => {
if (taskSingleClickTimer) { clearTimeout(taskSingleClickTimer); taskSingleClickTimer = null; return; }
taskSingleClickTimer = setTimeout(() => {
taskSingleClickTimer = null;
lockPathsThroughNode(node);
}, 260);
});
state.sigma.on("doubleClickNode", (e) => {
const { node } = e;
if (e.preventSigmaDefault) e.preventSigmaDefault();
if (e.event?.original?.preventDefault) e.event.original.preventDefault();
if (taskSingleClickTimer) { clearTimeout(taskSingleClickTimer); taskSingleClickTimer = null; }
if (state.transitioning) return;
resetTaskSelect();
navigateIntoNode(node);
});
// Click empty canvas → unlock and restore detail for the query node.
state.sigma.on("clickStage", () => {
if (state.lockedPaths) {
state.lockedPaths = null;
if (state.sigma) state.sigma.refresh();
if (state.currentNode) loadDetail(state.currentNode);
}
});
setTimeout(() => { state.showAllInitial = false; if (state.sigma) state.sigma.refresh(); }, 2000);
}
// ── Task-mode helpers: lock-through-node, claims fetch, dropdown reset ──
function lockPathsThroughNode(node) {
if (!state.graph || !state.graph.hasNode(node)) return;
const pids = state.graph.getNodeAttribute(node, "paths") || [];
if (!pids.length) return;
state.lockedPaths = new Set(pids);
if (state.sigma) state.sigma.refresh();
const subset = (state.taskPaths || []).filter((_, idx) => state.lockedPaths.has(idx));
loadPathClaims(subset, node);
}
async function loadPathClaims(paths, focusNode) {
const pane = $("detailPane");
pane.innerHTML = `<div class="loading"><span class="spinner"></span>${t("loading")}</div>`;
try {
const r = await fetch("/api/kg/path-claims", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ paths, limit: 30 }),
}).then((r) => r.json());
renderPathClaims(r, focusNode);
} catch (e) {
pane.innerHTML = `<div class="detail-empty" style="color:var(--danger);">load failed</div>`;
}
}
function renderPathClaims(data, focusNode) {
const pane = $("detailPane");
const edgesArr = data.edges || [];
const focusName = (state.graph && state.graph.hasNode(focusNode))
? (state.graph.getNodeAttribute(focusNode, "originalLabel") || focusNode)
: focusNode;
if (!edgesArr.length) {
pane.innerHTML = `
<div class="focus-banner">
<span>Locked chains through <b>${escapeHtml(focusName)}</b></span>
<span class="close-x" id="closePathBtn" title="Unlock">×</span>
</div>
<div class="detail-section"><div class="results-hint">${t("no_sources")}</div></div>`;
$("closePathBtn")?.addEventListener("click", unlockPaths);
return;
}
const sectionsHtml = edgesArr.map((edge) => {
const curated = edge.curated_edges || [];
const claims = edge.claims || [];
const curatedHtml = curated.length ? `
<div style="margin-bottom:8px;">
${curated.map((e) => `
<div class="item-card">
<div class="item-head">
<span class="predicate">${escapeHtml(e.relation_type || "?")}</span>
<span class="conf">conf ${fmt(e.confidence, 2)}</span>
</div>
<div class="triple">
<b>${escapeHtml(e.from_name)}</b>
<span class="arrow">→</span>
<b>${escapeHtml(e.to_name)}</b>
</div>
<div class="evidence-row">
<span>vocab: ${escapeHtml(e.source_vocab)}</span>
${e.evidence_ref ? `<span>${escapeHtml(e.evidence_ref.slice(0, 60))}</span>` : ""}
</div>
</div>`).join("")}
</div>` : "";
const claimsHtml = claims.map((c) => {
const pmid = c.paper?.pmid;
const doi = c.paper?.doi;
const year = c.paper?.year;
const paperLinks = [
pmid ? `<a class="badge link" href="${c.paper.pubmed_url}" target="_blank" rel="noopener">${t("pubmed")} ${pmid} ↗</a>` : null,
doi ? `<a class="badge link" href="${c.paper.doi_url}" target="_blank" rel="noopener">${t("doi")} ↗</a>` : null,
year ? `<span class="badge">${year}</span>` : null,
c.paper?.journal ? `<span class="badge" title="${escapeHtml(c.paper.journal)}">${escapeHtml((c.paper.journal || "").slice(0, 26))}</span>` : null,
].filter(Boolean).join("");
const evRow = [
c.evidence?.study_type ? `<span>${escapeHtml(c.evidence.study_type)}</span>` : null,
c.evidence?.p_value != null ? `<span>p=${c.evidence.p_value}</span>` : null,
c.evidence?.sample_size ? `<span>n=${c.evidence.sample_size}</span>` : null,
].filter(Boolean).join("");
return `
<div class="item-card">
<div class="item-head">
<span class="predicate">${escapeHtml(c.predicate || "?")}</span>
<span class="conf">conf ${fmt(c.confidence, 2)}</span>
</div>
<div class="triple">
<b>${escapeHtml(c.subject_name)}</b>
<span class="arrow">→</span>
<b>${escapeHtml(c.object_name)}</b>
</div>
${c.raw_text ? `<div class="raw-text">${escapeHtml(c.raw_text)}</div>` : ""}
${evRow ? `<div class="evidence-row">${evRow}</div>` : ""}
${paperLinks ? `<div class="paper-row">${paperLinks}</div>` : ""}
</div>`;
}).join("");
return `
<div class="detail-section">
<h3>
<b>${escapeHtml(edge.from_name)}</b>
<span class="arrow"> ↔ </span>
<b>${escapeHtml(edge.to_name)}</b>
<span class="count">${edge.total_claims || 0}</span>
</h3>
${curatedHtml}
${claimsHtml}
</div>`;
}).join("");
pane.innerHTML = `
<div class="focus-banner">
<span>Locked chains through <b>${escapeHtml(focusName)}</b> · ${edgesArr.length} edges</span>
<span class="close-x" id="closePathBtn" title="Unlock">×</span>
</div>
${sectionsHtml}`;
$("closePathBtn")?.addEventListener("click", unlockPaths);
}
function unlockPaths() {
state.lockedPaths = null;
if (state.sigma) state.sigma.refresh();
if (state.currentNode) loadDetail(state.currentNode);
}
function resetTaskSelect() {
const sel = $("taskSelect");
if (sel) sel.value = "";
const strictBox = $("strictChainToggle");
if (strictBox) { strictBox.checked = false; strictBox.disabled = true; }
state.taskPaths = [];
state.lockedPaths = null;
refreshTaskOptionAvailability();
}
async function loadNeighborhood(nodeId) {
const url = new URL(`/api/kg/node/${encodeURIComponent(nodeId)}/neighborhood`, location.origin);
url.searchParams.set("depth", state.depth);
url.searchParams.set("limit", state.limit);
if (state.edgeTypes && state.edgeTypes !== "all") {
url.searchParams.set("edge_types", state.edgeTypes);
}
try {
const r = await fetch(url).then((r) => r.json());
state.cachedNeighborhood = r;
renderGraph(r);
} catch (e) {
$("graphInfo").textContent = "neighborhood load failed";
}
}
// ── Graph render with hover reducers ─────────────────────────────────
function renderGraph(data) {
$("graphEmpty").style.display = "none";
$("legend").style.display = "flex";
$("hoverHint").style.display = "block";
// 2-second "preview": show every color + label before dimming to gray.
// The flag is read by node/edge reducers each frame; flipping it at 2s
// + refresh transitions the graph to the usual hover-to-reveal mode.
state.showAllInitial = true;
$("hoverHint").textContent = t("preview_all");
const { nodes, edges, truncated } = data;
$("graphInfo").textContent = t("nodes_edges", nodes.length, edges.length, truncated);
// Kill previous sigma + worker
if (state.layoutWorker) { try { state.layoutWorker.kill(); } catch (e) {} state.layoutWorker = null; }
if (state.sigma) { state.sigma.kill(); state.sigma = null; }
const Graph = graphology.Graph;
const graph = new Graph({ type: "directed", multi: false, allowSelfLoops: true });
nodes.forEach((n, i) => {
const nodeDepth = n.depth || 0;
const base = n.is_claim ? 14 : 22;
const size = n.is_noise && !n.is_center ? Math.max(8, Math.round(base * 0.65)) : base;
let x = 0, y = 0;
if (!n.is_center) {
if (state.layoutMode === "ring") {
const sameDepth = nodes.filter(nd => (nd.depth || 0) === nodeDepth && !nd.is_center);
const idx = sameDepth.indexOf(n);
const count = sameDepth.length;
const ang = (idx / count) * 2 * Math.PI + (nodeDepth * 0.3);
const r = nodeDepth === 1 ? 1.5 : 3.5;
x = Math.cos(ang) * r;
y = Math.sin(ang) * r;
} else {
const ang = (i / nodes.length) * 2 * Math.PI;
const r = nodeDepth >= 2 ? 2.5 + Math.random() * 1.0 : 0.8 + Math.random() * 1.0;
x = Math.cos(ang) * r;
y = Math.sin(ang) * r;
}
}
graph.addNode(n.id, {
label: n.label,
originalLabel: n.label,
size,
baseSize: size,
color: n.color,
baseColor: n.color,
x,
y,
fixed: !!n.is_center,
domain: n.domain,
is_center: n.is_center,
is_claim: n.is_claim,
is_noise: !!n.is_noise,
depth: nodeDepth,
});
});
// When depth>=2, the static view de-emphasises center↔depth-1 edges (so the
// depth-1↔depth-2 frontier is the focus). We *keep* them in the graph so
// that pinning/hovering a depth-1 node still lights up its edge to center;
// the edgeReducer hides them only when nothing nearby is active.
const depthMap = data.depth_map || {};
const isDepth2Mode = state.depth >= 2;
edges.forEach((e) => {
if (!graph.hasNode(e.source) || !graph.hasNode(e.target)) return;
if (graph.hasEdge(e.source, e.target)) return;
const sd = depthMap[e.source] ?? 0;
const td = depthMap[e.target] ?? 0;
// In depth-2 mode, we de-emphasise center↔depth-1 edges (the inner
// hop) so the depth-1↔depth-2 frontier reads cleanly. They stay in
// the graph so pinning a depth-1 node still lights up its center edge.
const isFrontier = isDepth2Mode && sd < 2 && td < 2;
graph.addEdge(e.source, e.target, {
label: e.label,
color: e.color,
baseColor: e.color,
size: 1.5,
relation: e.label,
relations_fwd: e.relations_fwd || [],
relations_rev: e.relations_rev || [],
bidirectional: !!e.bidirectional,
curvature: 0.2,
isFrontier,
});
});
state.graph = graph;
const container = $("sigma-container");
const curveProgram = Sigma.rendering && Sigma.rendering.EdgeCurveProgram;
const edgeSettings = curveProgram
? { defaultEdgeType: "curved", edgeProgramClasses: { curved: curveProgram } }
: { defaultEdgeType: "arrow" };
state.sigma = new Sigma(graph, container, {
...edgeSettings,
padding: 100,
renderEdgeLabels: false,
labelSize: state.fontSize,
labelWeight: "600",
labelColor: { color: getComputedStyle(document.documentElement).getPropertyValue("--text").trim() || "#0f172a" },
labelRenderedSizeThreshold: 4,
// node/edge reducers run per-frame and let us recolor based on state
nodeReducer(nid, data) {
const res = { ...data };
// Initial 2-second preview: everything in full color + labels.
if (state.showAllInitial) {
res.color = data.baseColor;
res.label = data.originalLabel;
if (data.is_center) res.size = data.baseSize;
return res;
}
const hover = state.hoveredNode;
const pin = state.pinnedNode;
// Query node always stays colored
if (data.is_center) {
res.color = data.baseColor;
res.size = data.baseSize * (nid === hover ? 1.1 : 1.0);
res.label = data.originalLabel;
return res;
}
// Active node set: union of hover + pin
// - If hovering query: show everything (all neighbors lit)
// - If hovering a specific node: that node + its neighbors
// - If a node is pinned (persistent): that node + its neighbors stay lit
let lightAll = false;
const active = new Set();
if (hover === state.currentNode) lightAll = true;
if (hover && hover !== state.currentNode) {
active.add(hover);
state.graph && state.graph.forEachNeighbor(hover, (nb) => active.add(nb));
}
if (pin) {
active.add(pin);
state.graph && state.graph.forEachNeighbor(pin, (nb) => active.add(nb));
}
if (lightAll || active.has(nid)) {
res.color = data.baseColor;
res.label = data.originalLabel;
// Emphasis: hovered > pinned > other
if (nid === hover) res.size = data.baseSize * 1.2;
else if (nid === pin) res.size = data.baseSize * 1.12;
} else {
res.color = GRAY;
res.label = "";
}
// Pinned node gets a subtle ring via increased size even without hover
return res;
},
edgeReducer(eid, data) {
const res = { ...data };
// Initial 2-second preview: all edges colored + labeled.
if (state.showAllInitial) {
res.color = data.baseColor;
res.label = data.relation;
res.size = 2;
return res;
}
const src = state.graph.source(eid);
const tgt = state.graph.target(eid);
const hover = state.hoveredNode;
const pin = state.pinnedNode;
const lightAll = hover === state.currentNode;
// Edge is "active" if it's incident to hoveredNode or pinnedNode,
// or if we're hovering the center (lightAll)
const hoverIncident = hover && hover !== state.currentNode
&& (src === hover || tgt === hover);
const pinIncident = pin && (src === pin || tgt === pin);
if (lightAll || hoverIncident || pinIncident) {
res.color = data.baseColor;
res.label = data.relation;
// Hover takes visual priority
if (hoverIncident) res.size = 2.5;
else if (pinIncident) res.size = 2.2;
else res.size = 2;
} else if (data.isFrontier) {
// depth-2 mode: center↔depth-1 edges are de-emphasised when nothing
// is hovered/pinned, but stay in the graph so pinning lights them up.
res.color = DIM_EDGE;
res.label = "";
res.size = 1;
res.hidden = true;
} else {
res.color = DIM_EDGE;
res.label = "";
res.size = 1;
}
return res;
},
});
state.sigma.setSetting("renderEdgeLabels", false);
try {
const settings = graphologyLibrary.layoutForceAtlas2.inferSettings(graph);
if (state.layoutMode === "ring") {
settings.gravity = 0.5;
settings.scalingRatio = 3;
settings.strongGravityMode = true;
}
state.layoutWorker = new graphologyLibrary.FA2Layout(graph, { settings });
state.layoutWorker.start();
const centerId = state.currentNode;
const pinInterval = setInterval(() => {
if (!state.graph || !centerId) return;
if (state.graph.hasNode(centerId)) {
state.graph.setNodeAttribute(centerId, "x", 0);
state.graph.setNodeAttribute(centerId, "y", 0);
}
if (!state.transitioning) {
centerCameraOn(centerId, 2.0);
}
}, 60);
const layoutDuration = state.layoutMode === "ring" ? 1200 : 2500;
setTimeout(() => {
try { state.layoutWorker && state.layoutWorker.stop(); } catch (e) {}
clearInterval(pinInterval);
if (state.graph && centerId && state.graph.hasNode(centerId)) {
state.graph.setNodeAttribute(centerId, "x", 0);
state.graph.setNodeAttribute(centerId, "y", 0);
}
if (!state.transitioning) centerCameraOn(centerId, 2.0);
if (state.sigma) state.sigma.refresh();
}, layoutDuration);
// Initial snap so the first render already targets the node.
centerCameraOn(centerId, 2.0);
} catch (e) {
console.warn("layout failed:", e);
}
// Hover events — update state + refresh sigma for reducers to re-run
state.sigma.on("enterNode", ({ node }) => {
state.hoveredNode = node;
container.style.cursor = "pointer";
const nodeData = state.graph.getNodeAttributes(node);
const label = nodeData.originalLabel || node;
$("hoverHint").textContent = node === state.currentNode ? t("hover_all") : t("hover_explain", label);
state.sigma.refresh();
});
state.sigma.on("leaveNode", () => {
state.hoveredNode = null;
container.style.cursor = "grab";
$("hoverHint").textContent = t("hover_show");
state.sigma.refresh();
});
// Click vs double-click: we detect a double-click via timer
let singleClickTimer = null;
state.sigma.on("clickNode", ({ node }) => {
if (singleClickTimer) { clearTimeout(singleClickTimer); singleClickTimer = null; return; }
singleClickTimer = setTimeout(() => {
singleClickTimer = null;
if (node === state.currentNode) {
// Clicking the query node: clear any pin and show node detail
state.pinnedNode = null;
state.focusEdge = null;
loadDetail(node);
state.sigma.refresh();
} else if (state.pinnedNode === node) {
// Same pinned node clicked again → toggle off (back to default gray)
state.pinnedNode = null;
state.focusEdge = null;
loadDetail(state.currentNode);
state.sigma.refresh();
} else {
// Pin this node and show edge sources (unpins any previous)
state.pinnedNode = node;
loadEdgeSources(state.currentNode, node);
state.sigma.refresh();
}
}, 260);
});
state.sigma.on("doubleClickNode", (e) => {
const { node } = e;
if (e.preventSigmaDefault) e.preventSigmaDefault();
if (e.event?.original?.preventDefault) e.event.original.preventDefault();
if (singleClickTimer) { clearTimeout(singleClickTimer); singleClickTimer = null; }
if (state.transitioning) return;
navigateIntoNode(node);
});
// Clicking empty canvas → clear pin and go back to node detail
state.sigma.on("clickStage", () => {
if (state.pinnedNode) {
state.pinnedNode = null;
state.focusEdge = null;
if (state.currentNode) loadDetail(state.currentNode);
state.sigma.refresh();
}
});
renderLegend();
// End the 2-second full-color preview. Flip the flag, update the hint to
// invite hover interaction, and trigger a refresh so reducers re-run in
// the normal dim-except-center mode.
setTimeout(() => {
state.showAllInitial = false;
if (!state.hoveredNode) $("hoverHint").textContent = t("hover_show");
if (state.sigma) state.sigma.refresh();
}, 2000);
}
function _hoverActiveSet() {
const s = new Set();
if (!state.hoveredNode || !state.graph) return s;
s.add(state.hoveredNode);
state.graph.forEachNeighbor(state.hoveredNode, (nb) => s.add(nb));
return s;
}
function renderLegend() {
if (!state.graph) return;
const domains = new Set();
state.graph.forEachNode((_, d) => d.domain && domains.add(d.domain));
// Collect unique relation types across both directions, preserving first-color seen
const relColors = new Map();
state.graph.forEachEdge((_, d) => {
const rels = [...(d.relations_fwd || []), ...(d.relations_rev || [])];
rels.forEach((r) => { if (!relColors.has(r)) relColors.set(r, d.baseColor); });
});
const domHtml = [...domains].map((d) =>
`<span class="legend-item"><span class="legend-dot" style="background:${ATOM_COLORS[d] || "#94a3b8"}"></span>${ATOM_FULL[d] || d}</span>`
).join("");
const edgeHtml = [...relColors.entries()].slice(0, 8).map(([rel, c]) =>
`<span class="legend-item"><span class="legend-line" style="background:${c}"></span>${escapeHtml(rel)}</span>`
).join("");
const nodeRow = `<div class="legend-row"><span class="legend-row-label">${t("node_type")}</span>${domHtml || '<span class="legend-item" style="opacity:0.5;">—</span>'}</div>`;
const edgeRow = edgeHtml
? `<div class="legend-row"><span class="legend-row-label">${t("edge_type_legend")}</span>${edgeHtml}</div>`
: "";
$("legend").innerHTML = `<span class="legend-close" id="legendClose" style="position:absolute;top:6px;right:10px;cursor:pointer;font-size:18px;color:var(--text-muted);">✕</span>` + nodeRow + edgeRow;
$("legendClose").addEventListener("click", () => {
$("legend").classList.add("collapsed");
$("legendToggle").classList.add("visible");
});
}
// ── Detail pane: node ────────────────────────────────────────────────
async function loadDetail(nodeId) {
const pane = $("detailPane");
pane.innerHTML = `<div class="loading"><span class="spinner"></span>${t("loading")}</div>`;
try {
const predParam = state.edgeTypes && state.edgeTypes !== "all" ? `&predicate=${encodeURIComponent(state.edgeTypes)}` : "";
const [concept, claims, hyps] = await Promise.all([
fetch(`/api/kg/node/${encodeURIComponent(nodeId)}`).then((r) => r.json()),
fetch(`/api/kg/node/${encodeURIComponent(nodeId)}/claims?limit=80${predParam}`).then((r) => r.json()),
fetch(`/api/kg/node/${encodeURIComponent(nodeId)}/hypotheses?limit=30${state.recipeOnly ? "&recipe_only=true" : ""}`).then((r) => r.json()),
]);
if (nodeId === state.currentNode) {
state.currentNodeAtoms = Array.isArray(concept.atoms) ? concept.atoms : [];
refreshTaskOptionAvailability();
}
renderDetail(concept, claims, hyps);
} catch (e) {
pane.innerHTML = `<div class="detail-empty" style="color:var(--danger);">load failed</div>`;
}
}
function renderDetail(concept, claimsResp, hypsResp) {
const pane = $("detailPane");
const claims = claimsResp.claims || [];
const hyps = hypsResp.hypotheses || [];
state.hasRecipes = hypsResp.has_recipes;
const externalHtml = (concept.external_links || []).map((l) =>
l.url
? `<a class="badge link" href="${escapeHtml(l.url)}" target="_blank" rel="noopener">${escapeHtml(l.label)} ↗</a>`
: `<span class="badge">${escapeHtml(l.label)}</span>`
).join("");
const aliasesHtml = (concept.aliases || []).length
? `${t("aliases")}: ${(concept.aliases || []).slice(0, 6).map(escapeHtml).join(" · ")}`
: "";
const noiseBadge = concept.is_noise
? `<span class="noise-badge" title="${escapeHtml((concept.noise_reasons || []).join(' | ') || 'low quality')}">${t("low_quality")} ${concept.noise_score != null ? "(" + fmt(concept.noise_score, 2) + ")" : ""}</span>`
: "";
const predInfo = (state.edgeTypes && state.edgeTypes !== "all")
? `<div class="focus-banner" style="background:rgba(217,119,6,0.08);"><span>${t("predicate_filter", state.edgeTypes)}</span></div>` : "";
pane.innerHTML = `
<div class="detail-section">
<div class="detail-title">${escapeHtml(concept.name)}${noiseBadge}</div>
${aliasesHtml ? `<div class="detail-aliases">${aliasesHtml}</div>` : ""}
<div class="badge-row">
${atomBadges(concept.atoms)}
<span class="badge" style="font-family:ui-monospace,monospace;">${escapeHtml(concept.id)}</span>
</div>
${concept.definition ? `<div class="detail-definition">${escapeHtml(concept.definition)}</div>` : ""}
${externalHtml ? `<div class="badge-row" style="margin-top:12px;">${externalHtml}</div>` : ""}
</div>
<div class="tab-row">
<button class="tab-btn ${state.activeTab === "hypotheses" ? "active" : ""}" data-tab="hypotheses">
${t("tab_hypotheses")}<span class="count">${hypsResp.total || 0}</span>
</button>
<button class="tab-btn ${state.activeTab === "claims" ? "active" : ""}" data-tab="claims">
${t("tab_claims")}<span class="count">${claimsResp.total || 0}</span>
</button>
<button class="tab-btn ${state.activeTab === "meta" ? "active" : ""}" data-tab="meta">
${t("tab_meta")}
</button>
</div>
${predInfo}
${state.hasRecipes ? `
<div class="recipe-filter-row">
<input type="checkbox" id="recipeOnlyToggle" ${state.recipeOnly ? "checked" : ""}>
<label for="recipeOnlyToggle">${t("filter_recipe")}</label>
</div>` : ""}
<div id="tabBody"></div>
<details class="paths-section">
<summary>${t("paths_section_title")}</summary>
<div class="paths-body">
<div class="paths-controls">
<input type="text" id="pathTargetInput" placeholder="${t('paths_target_placeholder')}" autocomplete="off"/>
<select id="pathMaxHops">
<option value="2">2 ${t("paths_max_hops")}</option>
<option value="3" selected>3 ${t("paths_max_hops")}</option>
<option value="4">4 ${t("paths_max_hops")}</option>
</select>
</div>
<div id="pathTargetSuggestions" class="paths-suggestions"></div>
<div id="pathsResults" class="paths-results"></div>
</div>
</details>
`;
pane.querySelectorAll(".tab-btn").forEach((b) => {
b.addEventListener("click", () => {
state.activeTab = b.dataset.tab;
pane.querySelectorAll(".tab-btn").forEach((x) => x.classList.toggle("active", x === b));
renderTabBody(concept, claims, hyps);
});
});
const recipeToggle = $("recipeOnlyToggle");
if (recipeToggle) {
recipeToggle.addEventListener("change", (e) => {
state.recipeOnly = e.target.checked;
loadDetail(concept.id);
});
}
renderTabBody(concept, claims, hyps);
setupPathFinder(concept.id);
}
function renderTabBody(concept, claims, hyps) {
const body = $("tabBody");
if (!body) return;
if (state.activeTab === "hypotheses") body.innerHTML = renderHypothesesList(hyps);
else if (state.activeTab === "claims") body.innerHTML = renderClaimsList(claims);
else body.innerHTML = renderMetaPanel(concept);
}
// ── Path finder (on-demand; only runs when user expands & types) ────────────
function setupPathFinder(currentNodeId) {
const input = document.getElementById("pathTargetInput");
const sugBox = document.getElementById("pathTargetSuggestions");
const hopsSel = document.getElementById("pathMaxHops");
if (!input || !sugBox || !hopsSel) return;
let debounceTimer = null;
let lastPicked = null;
input.addEventListener("input", () => {
clearTimeout(debounceTimer);
const q = input.value.trim();
if (q.length < 2) { sugBox.innerHTML = ""; return; }
debounceTimer = setTimeout(async () => {
try {
const url = new URL("/api/kg/search", location.origin);
url.searchParams.set("q", q);
url.searchParams.set("limit", "8");
const r = await fetch(url).then((r) => r.json());
const items = r.results || [];
if (!items.length) { sugBox.innerHTML = `<div class="paths-empty" style="padding:6px 10px;">${t("no_match")}</div>`; return; }
sugBox.innerHTML = items.map((it) => {
const dt = atomBadges(it.atoms);
return `<div class="path-suggest" data-id="${escapeHtml(it.id)}" data-name="${escapeHtml(it.name)}">${escapeHtml(it.name)}${dt}</div>`;
}).join("");
sugBox.querySelectorAll(".path-suggest").forEach((div) => {
div.addEventListener("click", () => {
input.value = div.dataset.name;
lastPicked = div.dataset.id;
sugBox.innerHTML = "";
findPaths(currentNodeId, div.dataset.id, parseInt(hopsSel.value, 10));
});
});
} catch (e) {
sugBox.innerHTML = "";
}
}, 250);
});
// re-run on hop change if a target was already selected
hopsSel.addEventListener("change", () => {
if (lastPicked) findPaths(currentNodeId, lastPicked, parseInt(hopsSel.value, 10));
});
}
async function findPaths(source, target, maxHops) {
const results = document.getElementById("pathsResults");
if (!results) return;
results.innerHTML = `<div class="loading"><span class="spinner"></span>${t("paths_searching")}</div>`;
try {
const url = new URL("/api/kg/paths", location.origin);
url.searchParams.set("source", source);
url.searchParams.set("target", target);
url.searchParams.set("max_hops", String(maxHops));
url.searchParams.set("max_paths", "50");
const r = await fetch(url).then((r) => r.json());
if (r.error) {
results.innerHTML = `<div class="paths-empty" style="color:var(--danger);">${escapeHtml(r.error)}</div>`;
return;
}
const paths = r.paths || [];
if (!paths.length) {
results.innerHTML = `<div class="paths-empty">${t("paths_no_results")}</div>`;
return;
}
const shown = r.displayed_paths != null ? r.displayed_paths : paths.length;
const total = r.total_paths != null ? r.total_paths : paths.length;
const plus = r.truncated_by === "count_cap" || r.truncated_by === "timeout";
let summary = t("paths_summary_text", shown, total, plus);
if (r.truncated_by) {
const reasonKey = "paths_reason_" + r.truncated_by;
const reasonText = t(reasonKey);
const finalReason = (typeof reasonText === "string" && reasonText !== reasonKey) ? reasonText : r.truncated_by;
summary += ` · ${t("paths_truncated", finalReason)}`;
}
let html = `<div class="paths-summary">${summary}</div>`;
html += paths.map(renderPathRow).join("");
results.innerHTML = html;
results.querySelectorAll(".path-node[data-id]").forEach((el) => {
el.addEventListener("click", (e) => {
e.preventDefault();
const id = el.dataset.id;
if (id) selectNode(id);
});
});
} catch (e) {
results.innerHTML = `<div class="paths-empty" style="color:var(--danger);">${t("load_failed")}</div>`;
}
}
function renderPathRow(path) {
const parts = [];
for (let i = 0; i < (path.nodes || []).length; i++) {
const n = path.nodes[i];
const color = ATOM_COLORS[n.atom] || "#94a3b8";
parts.push(`<span class="path-node" data-id="${escapeHtml(n.id)}" style="color:${color};">${escapeHtml(n.name)}</span>`);
if (i < (path.edges || []).length) {
const ed = path.edges[i];
const arrow = ed.direction === "in" ? "←" : "→";
parts.push(`<span class="path-edge">${arrow}<small>${escapeHtml(ed.predicate || "")}</small>${arrow}</span>`);
}
}
const conf = (path.avg_confidence || 0).toFixed(2);
return `<div class="path-row" title="length=${path.length} · conf=${conf}">${parts.join(" ")}</div>`;
}
function renderHypothesesList(hyps) {
if (!hyps.length) return `<div class="detail-section"><div class="results-hint">${t("no_hypotheses")}</div></div>`;
const cards = hyps.map((h) => {
const pathHtml = (h.path || []).slice(0, 4).map((p) => {
const pmid = p.paper?.pmid;
const pmidTag = pmid ? `<a class="badge link" href="${p.paper.pubmed_url}" target="_blank" rel="noopener">${t("pubmed")} ${pmid}</a>` : "";
return `
<div class="path-step">
<b>${escapeHtml(p.from_name)}</b>
<span class="rel">${escapeHtml(p.relation_type)}</span>
<b>${escapeHtml(p.to_name)}</b>
${pmidTag}
</div>`;
}).join("");
const recipeBadge = h.has_recipe ? `<span class="recipe-pill">recipe ✓</span>` : "";
const recipeDetail = h.recipe ? `
<div class="evidence-row" style="margin-top:8px;">
<span>dataset: <b>${escapeHtml(h.recipe.dataset || "?")}</b></span>
<span>model: ${escapeHtml(h.recipe.model_arch || "?")}</span>
<span>atlas: ${escapeHtml(h.recipe.atlas || "?")}</span>
<span>target: ${escapeHtml(h.recipe.target_outcome || "?")}</span>
</div>` : "";
return `
<div class="item-card">
<div class="item-head">
<div class="triple">
${escapeHtml(h.source_name)}
<span class="arrow">→</span>
${escapeHtml(h.target_name)}
${recipeBadge}
</div>
<div class="conf">${fmt(h.composite_score, 3)}</div>
</div>
<div class="score-grid">
<span>${t("composite")} <b>${fmt(h.composite_score, 3)}</b></span>
<span>${t("novelty")} <b>${fmt(h.novelty_score, 2)}</b></span>
<span>${t("evidence")} <b>${fmt(h.evidence_score, 2)}</b></span>
<span>${t("testability")} <b>${fmt(h.testability_score, 2)}</b></span>
${h.critic_score > 0 ? `<span>${t("critic")} <b>${fmt(h.critic_score, 2)}</b></span>` : ""}
</div>
${h.testability_reason ? `<div class="raw-text" style="margin-top:6px;">${escapeHtml(h.testability_reason)}</div>` : ""}
${pathHtml ? `<div class="path-list">${pathHtml}</div>` : ""}
${recipeDetail}
${(h.path || []).length > 4 ? `<div class="evidence-row"><span>${t("more_steps", h.path.length - 4)}</span></div>` : ""}
</div>`;
}).join("");
return `<div class="detail-section">${cards}</div>`;
}
function renderClaimsList(claims) {
if (!claims.length) return `<div class="detail-section"><div class="results-hint">${t("no_claims")}</div></div>`;
const cards = claims.map((c) => {
const pmid = c.paper?.pmid;
const doi = c.paper?.doi;
const year = c.paper?.year;
const paperLinks = [
pmid ? `<a class="badge link" href="${c.paper.pubmed_url}" target="_blank" rel="noopener">${t("pubmed")} ${pmid} ↗</a>` : null,
doi ? `<a class="badge link" href="${c.paper.doi_url}" target="_blank" rel="noopener">${t("doi")} ↗</a>` : null,
year ? `<span class="badge">${year}</span>` : null,
c.paper?.journal ? `<span class="badge" title="${escapeHtml(c.paper.journal)}">${escapeHtml((c.paper.journal || "").slice(0, 24))}</span>` : null,
].filter(Boolean).join("");
const evRow = [
c.evidence?.study_type ? `<span>${escapeHtml(c.evidence.study_type)}</span>` : null,
c.evidence?.p_value != null ? `<span>p=${c.evidence.p_value}</span>` : null,
c.evidence?.effect_size != null ? `<span>${escapeHtml(c.evidence.effect_metric || "effect")}=${c.evidence.effect_size}</span>` : null,
c.evidence?.sample_size ? `<span>n=${c.evidence.sample_size}</span>` : null,
c.evidence?.replicability ? `<span>${escapeHtml(c.evidence.replicability)}</span>` : null,
].filter(Boolean).join("");
return `
<div class="item-card">
<div class="item-head">
<span class="predicate">${escapeHtml(c.predicate || "?")}</span>
<span class="conf">conf ${fmt(c.confidence, 2)}</span>
</div>
<div class="triple">
<b>${escapeHtml(c.subject_name)}</b>
<span class="arrow">→</span>
<b>${escapeHtml(c.object_name)}</b>
${c.negated ? '<span class="badge" style="color:var(--danger);">NEG</span>' : ""}
</div>
${c.raw_text ? `<div class="raw-text">${escapeHtml(c.raw_text)}</div>` : ""}
${evRow ? `<div class="evidence-row">${evRow}</div>` : ""}
${paperLinks ? `<div class="paper-row">${paperLinks}</div>` : ""}
</div>`;
}).join("");
return `<div class="detail-section">${cards}</div>`;
}
function renderMetaPanel(concept) {
const kvRows = Object.entries(concept.external_ids || {}).map(([k, v]) => `
<div class="kv"><span>${escapeHtml(k)}</span><code>${escapeHtml(String(v))}</code></div>
`).join("");
const atlas = concept.atlas_mapping
? `<div class="detail-section"><h3>${t("atlas_mapping")}</h3><pre style="font-size:11.5px;color:var(--text-muted);white-space:pre-wrap;">${escapeHtml(JSON.stringify(concept.atlas_mapping, null, 2))}</pre></div>` : "";
const semantic = (concept.semantic_types || []).length
? `<div class="detail-section"><h3>${t("semantic_types")}</h3><div class="badge-row">${concept.semantic_types.map((x) => `<span class="badge">${escapeHtml(x)}</span>`).join("")}</div></div>` : "";
return `
<div class="detail-section">
<h3>${t("external_ids")}</h3>
${kvRows ? `<div class="detail-meta-grid">${kvRows}</div>` : `<div class="results-hint">${t("no_external")}</div>`}
${concept.source_vocab ? `<div style="margin-top:10px;font-size:12.5px;color:var(--text-muted);">${t("source")}: <code>${escapeHtml(concept.source_vocab)}</code></div>` : ""}
</div>
${semantic}
${atlas}
`;
}
// ── Edge sources (single-click target) ───────────────────────────────
async function loadEdgeSources(source, target) {
state.focusEdge = { source, target };
const pane = $("detailPane");
pane.innerHTML = `<div class="loading"><span class="spinner"></span>${t("loading")}</div>`;
try {
const url = new URL("/api/kg/edge-sources", location.origin);
url.searchParams.set("source", source);
url.searchParams.set("target", target);
url.searchParams.set("limit", "80");
const r = await fetch(url).then((r) => r.json());
renderEdgeSources(r);
} catch (e) {
pane.innerHTML = `<div class="detail-empty" style="color:var(--danger);">load failed</div>`;
}
}
function renderEdgeSources(data) {
const pane = $("detailPane");
const srcName = data.source?.name || data.source?.id || "?";
const tgtName = data.target?.name || data.target?.id || "?";
const curated = data.curated_edges || [];
const claims = data.claims || [];
const hasNothing = curated.length === 0 && claims.length === 0;
const curatedHtml = curated.length ? `
<div class="detail-section">
<h3>${t("curated_edges")}<span class="count">${curated.length}</span></h3>
${curated.map((e) => `
<div class="item-card">
<div class="item-head">
<span class="predicate">${escapeHtml(e.relation_type || "?")}</span>
<span class="conf">conf ${fmt(e.confidence, 2)}</span>
</div>
<div class="triple">
<b>${escapeHtml(e.from_name)}</b>
<span class="arrow">→</span>
<b>${escapeHtml(e.to_name)}</b>
</div>
<div class="evidence-row">
<span>vocab: ${escapeHtml(e.source_vocab)}</span>
${e.evidence_ref ? `<span>${escapeHtml(e.evidence_ref.slice(0, 60))}</span>` : ""}
</div>
</div>`).join("")}
</div>` : "";
const claimsHtml = claims.length ? `
<div class="detail-section">
<h3>${t("supporting_claims")}<span class="count">${data.total_claims || 0}</span></h3>
${claims.map((c) => {
const pmid = c.paper?.pmid;
const doi = c.paper?.doi;
const year = c.paper?.year;
const paperLinks = [
pmid ? `<a class="badge link" href="${c.paper.pubmed_url}" target="_blank" rel="noopener">${t("pubmed")} ${pmid} ↗</a>` : null,
doi ? `<a class="badge link" href="${c.paper.doi_url}" target="_blank" rel="noopener">${t("doi")} ↗</a>` : null,
year ? `<span class="badge">${year}</span>` : null,
c.paper?.journal ? `<span class="badge" title="${escapeHtml(c.paper.journal)}">${escapeHtml((c.paper.journal || "").slice(0, 26))}</span>` : null,
].filter(Boolean).join("");
const evRow = [
c.evidence?.study_type ? `<span>${escapeHtml(c.evidence.study_type)}</span>` : null,
c.evidence?.p_value != null ? `<span>p=${c.evidence.p_value}</span>` : null,
c.evidence?.sample_size ? `<span>n=${c.evidence.sample_size}</span>` : null,
].filter(Boolean).join("");
return `
<div class="item-card">
<div class="item-head">
<span class="predicate">${escapeHtml(c.predicate || "?")}</span>
<span class="conf">conf ${fmt(c.confidence, 2)}</span>
</div>
<div class="triple">
<b>${escapeHtml(c.subject_name)}</b>
<span class="arrow">→</span>
<b>${escapeHtml(c.object_name)}</b>
</div>
${c.raw_text ? `<div class="raw-text">${escapeHtml(c.raw_text)}</div>` : ""}
${evRow ? `<div class="evidence-row">${evRow}</div>` : ""}
${paperLinks ? `<div class="paper-row">${paperLinks}</div>` : ""}
</div>`;
}).join("")}
</div>` : "";
pane.innerHTML = `
<div class="focus-banner">
<span>${t("showing_edge")}: <b>${escapeHtml(srcName)}</b>${t("and")}<b>${escapeHtml(tgtName)}</b></span>
<span class="close-x" id="closeFocusBtn" title="Back to node detail">×</span>
</div>
${hasNothing ? `<div class="detail-section"><div class="results-hint">${t("no_sources")}</div></div>` : ""}
${curatedHtml}
${claimsHtml}
`;
$("closeFocusBtn")?.addEventListener("click", () => {
state.focusEdge = null;
state.pinnedNode = null;
if (state.sigma) state.sigma.refresh();
if (state.currentNode) loadDetail(state.currentNode);
});
}
// ── Graph controls ───────────────────────────────────────────────────
$("depthSelect").addEventListener("change", (e) => {
state.depth = parseInt(e.target.value, 10);
if (state.currentNode) loadNeighborhood(state.currentNode);
});
$("limitInput").addEventListener("change", (e) => {
state.limit = Math.max(5, Math.min(200, parseInt(e.target.value, 10) || 60));
e.target.value = state.limit;
if (state.currentNode) loadNeighborhood(state.currentNode);
});
$("edgeTypeSelect").addEventListener("change", (e) => {
state.edgeTypes = e.target.value;
if (state.currentNode) {
loadNeighborhood(state.currentNode);
if (state.focusEdge) loadEdgeSources(state.focusEdge.source, state.focusEdge.target);
else loadDetail(state.currentNode);
}
});
$("layoutSelect").addEventListener("change", (e) => {
state.layoutMode = e.target.value;
if (state.currentNode) loadNeighborhood(state.currentNode);
});
$("legendToggle").addEventListener("click", () => {
$("legend").classList.remove("collapsed");
$("legendToggle").classList.remove("visible");
});
</script>
</body>
</html>