Explorer: 4 science scenarios, BFS hop filter, live query section
Browse files- Replace placeholder HotpotQA nodes with 4 real science scenarios:
General Relativity, DNA Central Dogma, Photosynthesis, Quantum Mechanics
- BFS computeReachability() drives hop slider — visible nodes/edges react
- Hop depth badge on each node; reasoning steps dim beyond hop count
- Stats panel shows Visible Nodes X/Y and Visible Edges A/B
- Live Query section: calls /api/compare, renders graphrag.entities as star graph
web/src/components/explorer/ExplorerContent.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
-
import { useState, useMemo } from "react";
|
| 4 |
|
| 5 |
interface GraphNode {
|
| 6 |
id: string;
|
|
@@ -17,108 +17,276 @@ interface GraphEdge {
|
|
| 17 |
label: string;
|
| 18 |
}
|
| 19 |
|
| 20 |
-
|
| 21 |
-
PERSON: "#FF6B6B",
|
| 22 |
-
ORGANIZATION: "#4ECDC4",
|
| 23 |
-
LOCATION: "#45B7D1",
|
| 24 |
-
EVENT: "#FFA07A",
|
| 25 |
-
DATE: "#98D8C8",
|
| 26 |
-
CONCEPT: "#AED6F1",
|
| 27 |
-
WORK: "#F9E79F",
|
| 28 |
-
QUERY: "#FF6B00",
|
| 29 |
-
};
|
| 30 |
-
|
| 31 |
-
const SCENARIOS: {
|
| 32 |
name: string;
|
| 33 |
query: string;
|
| 34 |
nodes: GraphNode[];
|
| 35 |
edges: GraphEdge[];
|
| 36 |
reasoning: string[];
|
| 37 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
{
|
| 39 |
-
name: "
|
| 40 |
-
query: "
|
| 41 |
nodes: [
|
| 42 |
-
{ id: "q",
|
| 43 |
-
{ id: "
|
| 44 |
-
{ id: "
|
| 45 |
-
{ id: "
|
| 46 |
-
{ id: "
|
| 47 |
-
{ id: "
|
| 48 |
-
{ id: "
|
| 49 |
-
{ id: "
|
| 50 |
-
{ id: "
|
|
|
|
| 51 |
],
|
| 52 |
edges: [
|
| 53 |
-
{ source: "q",
|
| 54 |
-
{ source: "q",
|
| 55 |
-
{ source: "
|
| 56 |
-
{ source: "
|
| 57 |
-
{ source: "
|
| 58 |
-
{ source: "
|
| 59 |
-
{ source: "
|
| 60 |
-
{ source: "
|
| 61 |
-
{ source: "
|
| 62 |
-
{ source: "
|
| 63 |
],
|
| 64 |
reasoning: [
|
| 65 |
-
"Entry:
|
| 66 |
-
"Hop 1:
|
| 67 |
-
"Hop 2:
|
| 68 |
-
"
|
| 69 |
],
|
| 70 |
},
|
| 71 |
{
|
| 72 |
-
name: "
|
| 73 |
-
query: "Which
|
| 74 |
nodes: [
|
| 75 |
-
{ id: "q",
|
| 76 |
-
{ id: "
|
| 77 |
-
{ id: "
|
| 78 |
-
{ id: "
|
| 79 |
-
{ id: "
|
| 80 |
-
{ id: "
|
| 81 |
-
{ id: "
|
|
|
|
|
|
|
|
|
|
| 82 |
],
|
| 83 |
edges: [
|
| 84 |
-
{ source: "q",
|
| 85 |
-
{ source: "q",
|
| 86 |
-
{ source: "
|
| 87 |
-
{ source: "
|
| 88 |
-
{ source: "
|
| 89 |
-
{ source: "
|
| 90 |
-
{ source: "
|
| 91 |
-
{ source: "
|
|
|
|
|
|
|
| 92 |
],
|
| 93 |
reasoning: [
|
| 94 |
-
"Entry:
|
| 95 |
-
"Hop 1:
|
| 96 |
-
"
|
| 97 |
-
"
|
| 98 |
],
|
| 99 |
},
|
| 100 |
];
|
| 101 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 102 |
export function ExplorerContent() {
|
| 103 |
const [scenarioIdx, setScenarioIdx] = useState(0);
|
| 104 |
const [selectedNode, setSelectedNode] = useState<string | null>(null);
|
| 105 |
-
const [hops, setHops] = useState(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
|
| 107 |
const scenario = SCENARIOS[scenarioIdx];
|
| 108 |
const { nodes, edges, reasoning } = scenario;
|
| 109 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
const nodeMap = useMemo(() => {
|
| 111 |
-
const
|
| 112 |
-
nodes.forEach(
|
| 113 |
-
return
|
| 114 |
}, [nodes]);
|
| 115 |
|
| 116 |
const selectedInfo = selectedNode ? nodeMap[selectedNode] : null;
|
| 117 |
-
|
| 118 |
const connectedEdges = selectedNode
|
| 119 |
-
?
|
| 120 |
: [];
|
| 121 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
return (
|
| 123 |
<div>
|
| 124 |
{/* Scenario Selector */}
|
|
@@ -126,7 +294,7 @@ export function ExplorerContent() {
|
|
| 126 |
<div className="flex flex-col md:flex-row gap-6 items-start md:items-end">
|
| 127 |
<div className="flex-1">
|
| 128 |
<div className="caption-uppercase mb-2" style={{ color: "var(--color-tiger-orange)" }}>Scenario</div>
|
| 129 |
-
<div className="flex gap-
|
| 130 |
{SCENARIOS.map((s, i) => (
|
| 131 |
<button
|
| 132 |
key={i}
|
|
@@ -143,95 +311,119 @@ export function ExplorerContent() {
|
|
| 143 |
</div>
|
| 144 |
<div className="flex items-center gap-4">
|
| 145 |
<label className="caption whitespace-nowrap flex items-center gap-2">
|
| 146 |
-
Hops:
|
| 147 |
-
<
|
| 148 |
-
|
| 149 |
-
|
|
|
|
|
|
|
|
|
|
| 150 |
</label>
|
|
|
|
|
|
|
|
|
|
| 151 |
</div>
|
| 152 |
</div>
|
| 153 |
</div>
|
| 154 |
|
| 155 |
<div className="grid grid-cols-1 xl:grid-cols-4 gap-6">
|
| 156 |
-
{/* Graph
|
| 157 |
<div className="xl:col-span-3">
|
| 158 |
<div className="card animate-scale-in" style={{ padding: "16px" }}>
|
| 159 |
-
<svg viewBox="0 0 900 520" style={{ width: "100%",
|
| 160 |
<defs>
|
| 161 |
<filter id="glow">
|
| 162 |
-
<feGaussianBlur stdDeviation="
|
| 163 |
<feMerge>
|
| 164 |
<feMergeNode in="coloredBlur"/>
|
| 165 |
<feMergeNode in="SourceGraphic"/>
|
| 166 |
</feMerge>
|
| 167 |
</filter>
|
| 168 |
-
<marker id="
|
| 169 |
-
<polygon points="0 0,
|
|
|
|
|
|
|
|
|
|
| 170 |
</marker>
|
| 171 |
</defs>
|
| 172 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
{/* Edges */}
|
| 174 |
-
{
|
| 175 |
const s = nodeMap[edge.source];
|
| 176 |
const t = nodeMap[edge.target];
|
| 177 |
if (!s || !t) return null;
|
| 178 |
const isConnected = selectedNode && (edge.source === selectedNode || edge.target === selectedNode);
|
| 179 |
-
const
|
| 180 |
const mx = (s.x + t.x) / 2;
|
| 181 |
-
const my = (s.y + t.y) / 2 -
|
| 182 |
return (
|
| 183 |
-
<g key={`edge-${i}`}>
|
| 184 |
<line
|
| 185 |
x1={s.x} y1={s.y} x2={t.x} y2={t.y}
|
| 186 |
-
stroke={isConnected ? "#FF6B00" : "#
|
| 187 |
-
strokeWidth={isConnected ?
|
| 188 |
-
|
| 189 |
-
markerEnd={isConnected ? undefined : "url(#arrowhead)"}
|
| 190 |
/>
|
| 191 |
-
{
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
)}
|
| 197 |
</g>
|
| 198 |
);
|
| 199 |
})}
|
| 200 |
|
| 201 |
{/* Nodes */}
|
| 202 |
-
{
|
| 203 |
const color = TYPE_COLORS[node.type] || "#AED6F1";
|
| 204 |
const isSelected = selectedNode === node.id;
|
| 205 |
const isConnected = connectedEdges.some(e => e.source === node.id || e.target === node.id);
|
| 206 |
-
const r = isSelected ? 26 : 20;
|
| 207 |
const dimmed = selectedNode && !isSelected && !isConnected;
|
|
|
|
|
|
|
|
|
|
| 208 |
return (
|
| 209 |
<g
|
| 210 |
key={node.id}
|
| 211 |
-
|
| 212 |
onClick={() => setSelectedNode(isSelected ? null : node.id)}
|
| 213 |
-
opacity={dimmed ? 0.
|
| 214 |
>
|
|
|
|
| 215 |
{isSelected && (
|
| 216 |
<>
|
| 217 |
-
<circle cx={node.x} cy={node.y} r={r +
|
| 218 |
-
<circle cx={node.x} cy={node.y} r={r +
|
| 219 |
-
filter="url(#glow)" />
|
| 220 |
</>
|
| 221 |
)}
|
| 222 |
<circle
|
| 223 |
cx={node.x} cy={node.y} r={r}
|
| 224 |
fill={color}
|
| 225 |
-
stroke="white"
|
|
|
|
| 226 |
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 227 |
{node.type === "QUERY" && (
|
| 228 |
-
<text x={node.x} y={node.y +
|
| 229 |
-
fontSize="
|
| 230 |
)}
|
| 231 |
<text
|
| 232 |
-
x={node.x} y={node.y + r +
|
| 233 |
textAnchor="middle"
|
| 234 |
-
fontSize={isSelected ? "
|
| 235 |
fontWeight={isSelected ? "600" : "400"}
|
| 236 |
fill="#141413"
|
| 237 |
fontFamily="var(--font-sans)"
|
|
@@ -255,12 +447,19 @@ export function ExplorerContent() {
|
|
| 255 |
{selectedInfo ? (
|
| 256 |
<div>
|
| 257 |
<div className="title-lg" style={{ color: "#faf9f5" }}>{selectedInfo.label}</div>
|
| 258 |
-
<
|
| 259 |
-
|
| 260 |
-
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 264 |
{selectedInfo.description && (
|
| 265 |
<p className="body-sm mt-3" style={{ color: "#a09d96", lineHeight: 1.6 }}>
|
| 266 |
{selectedInfo.description}
|
|
@@ -268,23 +467,26 @@ export function ExplorerContent() {
|
|
| 268 |
)}
|
| 269 |
<div className="mt-4 pt-4" style={{ borderTop: "1px solid rgba(255,255,255,0.08)" }}>
|
| 270 |
<div className="caption mb-2" style={{ color: "#a09d96" }}>
|
| 271 |
-
{connectedEdges.length}
|
| 272 |
</div>
|
| 273 |
{connectedEdges.map((e, i) => {
|
| 274 |
-
const
|
| 275 |
-
const otherNode = nodeMap[
|
|
|
|
| 276 |
return (
|
| 277 |
-
<div key={i} className="flex items-center gap-2 mb-2
|
| 278 |
-
|
| 279 |
-
|
| 280 |
-
|
|
|
|
|
|
|
|
|
|
| 281 |
background: TYPE_COLORS[otherNode?.type ?? ""] || "#AED6F1",
|
| 282 |
}} />
|
| 283 |
-
<span style={{ color: "#
|
| 284 |
-
{e.label}
|
| 285 |
</span>
|
| 286 |
-
<span style={{ color: "#
|
| 287 |
-
<span style={{ color: "#faf9f5", fontSize: "0.8125rem" }}>
|
| 288 |
{otherNode?.label}
|
| 289 |
</span>
|
| 290 |
</div>
|
|
@@ -294,7 +496,8 @@ export function ExplorerContent() {
|
|
| 294 |
</div>
|
| 295 |
) : (
|
| 296 |
<p className="body-sm" style={{ color: "#a09d96" }}>
|
| 297 |
-
Click any node
|
|
|
|
| 298 |
</p>
|
| 299 |
)}
|
| 300 |
</div>
|
|
@@ -303,11 +506,11 @@ export function ExplorerContent() {
|
|
| 303 |
<div className="card-cream animate-fade-in-up delay-200">
|
| 304 |
<div className="caption-uppercase mb-3">Graph Statistics</div>
|
| 305 |
{[
|
| 306 |
-
{ label: "Nodes", value: nodes.length, color: "#FF6B00" },
|
| 307 |
-
{ label: "Edges", value: edges.length, color: "#0072CE" },
|
| 308 |
-
{ label: "
|
| 309 |
-
{ label: "
|
| 310 |
-
{ label: "
|
| 311 |
].map((s, i) => (
|
| 312 |
<div key={i} className="flex justify-between items-center py-2.5"
|
| 313 |
style={{ borderBottom: i < 4 ? "1px solid var(--color-hairline-soft)" : "none" }}>
|
|
@@ -328,6 +531,9 @@ export function ExplorerContent() {
|
|
| 328 |
</div>
|
| 329 |
))}
|
| 330 |
</div>
|
|
|
|
|
|
|
|
|
|
| 331 |
</div>
|
| 332 |
</div>
|
| 333 |
</div>
|
|
@@ -336,21 +542,116 @@ export function ExplorerContent() {
|
|
| 336 |
<div className="card mt-8 animate-fade-in-up delay-400">
|
| 337 |
<div className="title-lg mb-6">🧠 Graph Reasoning Path</div>
|
| 338 |
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
| 339 |
-
{reasoning.map((step, i) =>
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
|
| 343 |
-
|
| 344 |
-
|
|
|
|
|
|
|
| 345 |
}}>
|
| 346 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 347 |
</div>
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 353 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 354 |
</div>
|
| 355 |
</div>
|
| 356 |
);
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
+
import { useState, useMemo, useCallback } from "react";
|
| 4 |
|
| 5 |
interface GraphNode {
|
| 6 |
id: string;
|
|
|
|
| 17 |
label: string;
|
| 18 |
}
|
| 19 |
|
| 20 |
+
interface Scenario {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
name: string;
|
| 22 |
query: string;
|
| 23 |
nodes: GraphNode[];
|
| 24 |
edges: GraphEdge[];
|
| 25 |
reasoning: string[];
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
const TYPE_COLORS: Record<string, string> = {
|
| 29 |
+
PERSON: "#FF6B6B",
|
| 30 |
+
ORGANIZATION: "#4ECDC4",
|
| 31 |
+
LOCATION: "#45B7D1",
|
| 32 |
+
MOLECULE: "#A29BFE",
|
| 33 |
+
ORGANELLE: "#55EFC4",
|
| 34 |
+
CONCEPT: "#AED6F1",
|
| 35 |
+
PROCESS: "#F9CA24",
|
| 36 |
+
CONSTANT: "#FD79A8",
|
| 37 |
+
ELEMENT: "#74B9FF",
|
| 38 |
+
QUERY: "#FF6B00",
|
| 39 |
+
};
|
| 40 |
+
|
| 41 |
+
// ── 4 Science Scenarios ────────────────────────────────────────────────────
|
| 42 |
+
const SCENARIOS: Scenario[] = [
|
| 43 |
+
{
|
| 44 |
+
name: "General Relativity",
|
| 45 |
+
query: "How does General Relativity predict gravitational waves?",
|
| 46 |
+
nodes: [
|
| 47 |
+
{ id: "q", label: "Query", type: "QUERY", x: 450, y: 260, description: "Entry point — identify key entities and traverse the graph" },
|
| 48 |
+
{ id: "einstein", label: "Albert Einstein", type: "PERSON", x: 200, y: 120, description: "Theoretical physicist; developed General Relativity (1915)" },
|
| 49 |
+
{ id: "gr", label: "General Relativity", type: "CONCEPT", x: 680, y: 120, description: "Geometric theory of gravitation; gravity = spacetime curvature" },
|
| 50 |
+
{ id: "spacetime", label: "Spacetime Curvature",type: "CONCEPT", x: 450, y: 90, description: "4D manifold warped by mass and energy — the mechanism of gravity" },
|
| 51 |
+
{ id: "grav_waves", label: "Gravitational Waves",type: "CONCEPT", x: 790, y: 270, description: "Ripples in spacetime produced by accelerating masses; predicted 1916" },
|
| 52 |
+
{ id: "black_holes",label: "Black Holes", type: "CONCEPT", x: 700, y: 420, description: "Regions where spacetime curvature prevents light escape; GR prediction" },
|
| 53 |
+
{ id: "ligo", label: "LIGO Detector", type: "ORGANIZATION", x: 450, y: 440, description: "Detected gravitational waves 14 Sep 2015 — confirmed GR's prediction" },
|
| 54 |
+
{ id: "eddington", label: "Eddington (1919)", type: "PERSON", x: 160, y: 320, description: "Observed light bending around the Sun during 1919 eclipse — first GR proof" },
|
| 55 |
+
{ id: "gps", label: "GPS Satellites", type: "CONCEPT", x: 160, y: 430, description: "Require GR time-dilation corrections; practical proof of the theory" },
|
| 56 |
+
],
|
| 57 |
+
edges: [
|
| 58 |
+
{ source: "q", target: "einstein", label: "FOUND_ENTITY" },
|
| 59 |
+
{ source: "q", target: "gr", label: "FOUND_ENTITY" },
|
| 60 |
+
{ source: "einstein", target: "gr", label: "DEVELOPED_1915" },
|
| 61 |
+
{ source: "einstein", target: "spacetime", label: "PROPOSED" },
|
| 62 |
+
{ source: "gr", target: "spacetime", label: "DESCRIBES" },
|
| 63 |
+
{ source: "gr", target: "grav_waves", label: "PREDICTS" },
|
| 64 |
+
{ source: "gr", target: "black_holes",label: "PREDICTS" },
|
| 65 |
+
{ source: "grav_waves", target: "ligo", label: "DETECTED_BY" },
|
| 66 |
+
{ source: "eddington", target: "gr", label: "CONFIRMED_1919" },
|
| 67 |
+
{ source: "gr", target: "gps", label: "CORRECTION_REQUIRED_BY" },
|
| 68 |
+
],
|
| 69 |
+
reasoning: [
|
| 70 |
+
"Entry: Query identifies Einstein and General Relativity as key entities",
|
| 71 |
+
"Hop 1: DEVELOPED_1915 → Einstein proposed spacetime curvature as gravity",
|
| 72 |
+
"Hop 2: PREDICTS edges → GR implies gravitational waves and black holes",
|
| 73 |
+
"Hop 3: DETECTED_BY → LIGO confirmed waves 100 years after prediction",
|
| 74 |
+
],
|
| 75 |
+
},
|
| 76 |
+
{
|
| 77 |
+
name: "DNA → Protein",
|
| 78 |
+
query: "How does DNA encode and produce proteins?",
|
| 79 |
+
nodes: [
|
| 80 |
+
{ id: "q", label: "Query", type: "QUERY", x: 450, y: 260, description: "Multi-hop biology question — trace the central dogma pathway" },
|
| 81 |
+
{ id: "dna", label: "DNA", type: "MOLECULE", x: 200, y: 130, description: "Double-helix polymer; stores genetic instructions via A-T-G-C base pairs" },
|
| 82 |
+
{ id: "rna", label: "mRNA", type: "MOLECULE", x: 700, y: 130, description: "Messenger RNA; transcribed copy of a gene, carries code to ribosome" },
|
| 83 |
+
{ id: "protein", label: "Protein", type: "MOLECULE", x: 450, y: 420, description: "Amino-acid chain folded into functional shape; performs cellular work" },
|
| 84 |
+
{ id: "watson_crick", label: "Watson & Crick", type: "PERSON", x: 80, y: 200, description: "Determined DNA double-helix structure (1953) using Franklin's X-ray data" },
|
| 85 |
+
{ id: "helicase", label: "Helicase", type: "MOLECULE", x: 200, y: 370, description: "Enzyme that unwinds the DNA double helix during replication/transcription" },
|
| 86 |
+
{ id: "ribosome", label: "Ribosome", type: "ORGANELLE",x: 700, y: 370, description: "Molecular machine that reads mRNA codons and assembles amino acids into protein" },
|
| 87 |
+
{ id: "nucleus", label: "Cell Nucleus", type: "ORGANELLE",x: 200, y: 260, description: "DNA is stored here; transcription (DNA→mRNA) occurs inside" },
|
| 88 |
+
{ id: "central_dogma",label: "Central Dogma", type: "CONCEPT", x: 450, y: 110, description: "Information flow: DNA → RNA → Protein (Crick, 1958)" },
|
| 89 |
+
],
|
| 90 |
+
edges: [
|
| 91 |
+
{ source: "q", target: "dna", label: "FOUND_ENTITY" },
|
| 92 |
+
{ source: "q", target: "protein", label: "FOUND_ENTITY" },
|
| 93 |
+
{ source: "watson_crick", target: "dna", label: "DISCOVERED_1953" },
|
| 94 |
+
{ source: "dna", target: "central_dogma", label: "DESCRIBED_BY" },
|
| 95 |
+
{ source: "dna", target: "nucleus", label: "LOCATED_IN" },
|
| 96 |
+
{ source: "helicase", target: "dna", label: "UNWINDS" },
|
| 97 |
+
{ source: "dna", target: "rna", label: "TRANSCRIBED_TO" },
|
| 98 |
+
{ source: "rna", target: "ribosome", label: "READ_BY" },
|
| 99 |
+
{ source: "ribosome", target: "protein", label: "PRODUCES" },
|
| 100 |
+
{ source: "central_dogma",target: "rna", label: "INCLUDES" },
|
| 101 |
+
],
|
| 102 |
+
reasoning: [
|
| 103 |
+
"Entry: Two key entities — DNA (information store) and Protein (output)",
|
| 104 |
+
"Hop 1: TRANSCRIBED_TO — DNA → mRNA; helicase unwinds the double helix",
|
| 105 |
+
"Hop 2: READ_BY — mRNA travels to ribosome in the cytoplasm",
|
| 106 |
+
"Hop 3: PRODUCES — Ribosome assembles amino acids into the final protein",
|
| 107 |
+
],
|
| 108 |
+
},
|
| 109 |
{
|
| 110 |
+
name: "Photosynthesis",
|
| 111 |
+
query: "What converts sunlight to glucose in plants?",
|
| 112 |
nodes: [
|
| 113 |
+
{ id: "q", label: "Query", type: "QUERY", x: 450, y: 260, description: "Factoid + multi-hop: identify the process and trace its pathway" },
|
| 114 |
+
{ id: "photosynthesis",label:"Photosynthesis", type: "PROCESS", x: 450, y: 110, description: "Converts light energy + CO₂ + H₂O → glucose + O₂; primary energy source for life" },
|
| 115 |
+
{ id: "chlorophyll", label: "Chlorophyll", type: "MOLECULE",x: 200, y: 140, description: "Green pigment in chloroplasts; absorbs red (~680 nm) and blue (~430 nm) light" },
|
| 116 |
+
{ id: "light", label: "Light Energy", type: "CONCEPT", x: 80, y: 260, description: "Solar radiation — the energy input that drives the entire process" },
|
| 117 |
+
{ id: "calvin_cycle", label: "Calvin Cycle", type: "PROCESS", x: 720, y: 190, description: "Light-independent reactions in stroma; uses ATP + NADPH to fix CO₂ into glucose" },
|
| 118 |
+
{ id: "glucose", label: "Glucose (C₆H₁₂O₆)",type:"MOLECULE",x:720, y: 370, description: "6-carbon sugar; stores chemical energy for the plant and food chain" },
|
| 119 |
+
{ id: "co2", label: "CO₂", type: "MOLECULE",x: 450, y: 430, description: "Carbon dioxide; fixed by RuBisCO enzyme in the Calvin Cycle" },
|
| 120 |
+
{ id: "water", label: "H₂O", type: "MOLECULE",x: 200, y: 370, description: "Split by photolysis in thylakoids; provides electrons and releases O₂" },
|
| 121 |
+
{ id: "oxygen", label: "O₂ (byproduct)", type: "MOLECULE",x: 80, y: 400, description: "Released during photolysis of water — the origin of Earth's atmospheric oxygen" },
|
| 122 |
+
{ id: "thylakoid", label: "Thylakoid", type: "ORGANELLE",x:350, y: 370, description: "Membrane system inside chloroplast; site of light-dependent reactions" },
|
| 123 |
],
|
| 124 |
edges: [
|
| 125 |
+
{ source: "q", target: "photosynthesis",label: "FOUND_ENTITY" },
|
| 126 |
+
{ source: "q", target: "chlorophyll", label: "FOUND_ENTITY" },
|
| 127 |
+
{ source: "light", target: "chlorophyll", label: "ABSORBED_BY" },
|
| 128 |
+
{ source: "chlorophyll", target: "photosynthesis",label: "DRIVES" },
|
| 129 |
+
{ source: "water", target: "photosynthesis",label: "INPUT" },
|
| 130 |
+
{ source: "water", target: "oxygen", label: "PHOTOLYSIS_PRODUCES" },
|
| 131 |
+
{ source: "co2", target: "calvin_cycle", label: "FIXED_BY" },
|
| 132 |
+
{ source: "photosynthesis",target: "calvin_cycle", label: "INCLUDES" },
|
| 133 |
+
{ source: "calvin_cycle", target: "glucose", label: "PRODUCES" },
|
| 134 |
+
{ source: "thylakoid", target: "photosynthesis",label: "LOCATION_OF" },
|
| 135 |
],
|
| 136 |
reasoning: [
|
| 137 |
+
"Entry: Photosynthesis and Chlorophyll identified as primary entities",
|
| 138 |
+
"Hop 1: ABSORBED_BY — light energy absorbed by chlorophyll in thylakoids",
|
| 139 |
+
"Hop 2: INCLUDES — photosynthesis triggers Calvin Cycle with CO₂ as input",
|
| 140 |
+
"Hop 3: PRODUCES — Calvin Cycle outputs glucose; water photolysis releases O₂",
|
| 141 |
],
|
| 142 |
},
|
| 143 |
{
|
| 144 |
+
name: "Quantum Mechanics Founders",
|
| 145 |
+
query: "Which physicists developed quantum mechanics and what did each contribute?",
|
| 146 |
nodes: [
|
| 147 |
+
{ id: "q", label: "Query", type: "QUERY", x: 450, y: 260, description: "Multi-hop comparison — identify multiple entities and their relationships" },
|
| 148 |
+
{ id: "qm", label: "Quantum Mechanics", type: "CONCEPT", x: 450, y: 110, description: "Physics of matter at atomic/subatomic scales; emerged from failures of classical physics" },
|
| 149 |
+
{ id: "bohr", label: "Niels Bohr", type: "PERSON", x: 180, y: 150, description: "Proposed quantized electron orbits (1913 Bohr model); founded Copenhagen interpretation" },
|
| 150 |
+
{ id: "heisenberg", label: "Heisenberg", type: "PERSON", x: 720, y: 150, description: "Formulated matrix mechanics (1925) and the uncertainty principle (1927)" },
|
| 151 |
+
{ id: "schrodinger", label: "Schrödinger", type: "PERSON", x: 180, y: 380, description: "Developed wave mechanics (1926); wave function ψ describes quantum state" },
|
| 152 |
+
{ id: "planck", label: "Max Planck", type: "PERSON", x: 720, y: 380, description: "Introduced energy quanta E=hf (1900) to explain blackbody radiation — started QM" },
|
| 153 |
+
{ id: "uncertainty", label: "Uncertainty Principle",type: "CONCEPT", x: 820, y: 260, description: "ΔxΔp ≥ ℏ/2 — position and momentum cannot both be precisely known simultaneously" },
|
| 154 |
+
{ id: "wave_fn", label: "Wave Function ψ", type: "CONCEPT", x: 80, y: 260, description: "Mathematical description of quantum state; |ψ|² gives probability density" },
|
| 155 |
+
{ id: "atom_model", label: "Bohr Atom Model", type: "CONCEPT", x: 80, y: 110, description: "Quantized electron energy levels; explained hydrogen emission spectrum (1913)" },
|
| 156 |
+
{ id: "photoelectric",label:"Photoelectric Effect", type: "CONCEPT", x: 820, y: 110, description: "Light ejects electrons from metal — explained by Einstein (1905), uses Planck's quanta" },
|
| 157 |
],
|
| 158 |
edges: [
|
| 159 |
+
{ source: "q", target: "qm", label: "FOUND_ENTITY" },
|
| 160 |
+
{ source: "q", target: "bohr", label: "FOUND_ENTITY" },
|
| 161 |
+
{ source: "planck", target: "qm", label: "FOUNDED_1900" },
|
| 162 |
+
{ source: "bohr", target: "qm", label: "DEVELOPED" },
|
| 163 |
+
{ source: "heisenberg", target: "qm", label: "DEVELOPED" },
|
| 164 |
+
{ source: "schrodinger", target: "qm", label: "DEVELOPED" },
|
| 165 |
+
{ source: "heisenberg", target: "uncertainty", label: "FORMULATED_1927" },
|
| 166 |
+
{ source: "schrodinger", target: "wave_fn", label: "PROPOSED_1926" },
|
| 167 |
+
{ source: "bohr", target: "atom_model", label: "PROPOSED_1913" },
|
| 168 |
+
{ source: "planck", target: "photoelectric", label: "QUANTA_EXPLAIN" },
|
| 169 |
],
|
| 170 |
reasoning: [
|
| 171 |
+
"Entry: Quantum Mechanics identified; four physicist entities extracted",
|
| 172 |
+
"Hop 1: FOUNDED/DEVELOPED edges — Planck, Bohr, Heisenberg, Schrödinger each contributed",
|
| 173 |
+
"Hop 2: Specific contributions — Uncertainty Principle, Wave Function, Bohr Atom",
|
| 174 |
+
"Convergence: All four paths meet at Quantum Mechanics — multi-founder answer confirmed",
|
| 175 |
],
|
| 176 |
},
|
| 177 |
];
|
| 178 |
|
| 179 |
+
// ── BFS hop reachability ───────────────────────────────────────────────────
|
| 180 |
+
function computeReachability(
|
| 181 |
+
nodes: GraphNode[],
|
| 182 |
+
edges: GraphEdge[],
|
| 183 |
+
maxHops: number,
|
| 184 |
+
): Map<string, number> {
|
| 185 |
+
const queryNode = nodes.find(n => n.type === "QUERY");
|
| 186 |
+
if (!queryNode) return new Map(nodes.map(n => [n.id, 0]));
|
| 187 |
+
|
| 188 |
+
const depths = new Map<string, number>();
|
| 189 |
+
const queue: { id: string; depth: number }[] = [{ id: queryNode.id, depth: 0 }];
|
| 190 |
+
|
| 191 |
+
while (queue.length > 0) {
|
| 192 |
+
const { id, depth } = queue.shift()!;
|
| 193 |
+
if (depths.has(id)) continue;
|
| 194 |
+
depths.set(id, depth);
|
| 195 |
+
if (depth < maxHops) {
|
| 196 |
+
for (const e of edges) {
|
| 197 |
+
if (e.source === id && !depths.has(e.target)) queue.push({ id: e.target, depth: depth + 1 });
|
| 198 |
+
if (e.target === id && !depths.has(e.source)) queue.push({ id: e.source, depth: depth + 1 });
|
| 199 |
+
}
|
| 200 |
+
}
|
| 201 |
+
}
|
| 202 |
+
return depths;
|
| 203 |
+
}
|
| 204 |
+
|
| 205 |
+
// ── Live query state ───────────────────────────────────────────────────────
|
| 206 |
+
interface LiveNode { id: string; label: string; x: number; y: number; hop: number }
|
| 207 |
+
interface LiveEdge { source: string; target: string }
|
| 208 |
+
|
| 209 |
+
function buildLiveGraph(entities: string[], query: string): { nodes: LiveNode[]; edges: LiveEdge[] } {
|
| 210 |
+
const cx = 450, cy = 240;
|
| 211 |
+
const nodes: LiveNode[] = [{ id: "q", label: query.slice(0, 32) + "…", x: cx, y: cy, hop: 0 }];
|
| 212 |
+
const edges: LiveEdge[] = [];
|
| 213 |
+
const r = 170;
|
| 214 |
+
|
| 215 |
+
entities.slice(0, 8).forEach((e, i) => {
|
| 216 |
+
const angle = (2 * Math.PI * i) / Math.min(entities.length, 8) - Math.PI / 2;
|
| 217 |
+
nodes.push({
|
| 218 |
+
id: `e${i}`,
|
| 219 |
+
label: e.slice(0, 30),
|
| 220 |
+
x: Math.round(cx + r * Math.cos(angle)),
|
| 221 |
+
y: Math.round(cy + r * Math.sin(angle)),
|
| 222 |
+
hop: 1,
|
| 223 |
+
});
|
| 224 |
+
edges.push({ source: "q", target: `e${i}` });
|
| 225 |
+
});
|
| 226 |
+
return { nodes, edges };
|
| 227 |
+
}
|
| 228 |
+
|
| 229 |
export function ExplorerContent() {
|
| 230 |
const [scenarioIdx, setScenarioIdx] = useState(0);
|
| 231 |
const [selectedNode, setSelectedNode] = useState<string | null>(null);
|
| 232 |
+
const [hops, setHops] = useState(3);
|
| 233 |
+
|
| 234 |
+
// Live query state
|
| 235 |
+
const [liveQuery, setLiveQuery] = useState("");
|
| 236 |
+
const [liveLoading, setLiveLoading] = useState(false);
|
| 237 |
+
const [liveGraph, setLiveGraph] = useState<{ nodes: LiveNode[]; edges: LiveEdge[] } | null>(null);
|
| 238 |
+
const [liveError, setLiveError] = useState("");
|
| 239 |
|
| 240 |
const scenario = SCENARIOS[scenarioIdx];
|
| 241 |
const { nodes, edges, reasoning } = scenario;
|
| 242 |
|
| 243 |
+
// BFS hop filter — what actually changes when the slider moves
|
| 244 |
+
const reachabilityMap = useMemo(
|
| 245 |
+
() => computeReachability(nodes, edges, hops),
|
| 246 |
+
[nodes, edges, hops],
|
| 247 |
+
);
|
| 248 |
+
const visibleNodes = useMemo(() => nodes.filter(n => reachabilityMap.has(n.id)), [nodes, reachabilityMap]);
|
| 249 |
+
const visibleEdges = useMemo(
|
| 250 |
+
() => edges.filter(e => reachabilityMap.has(e.source) && reachabilityMap.has(e.target)),
|
| 251 |
+
[edges, reachabilityMap],
|
| 252 |
+
);
|
| 253 |
+
|
| 254 |
const nodeMap = useMemo(() => {
|
| 255 |
+
const m: Record<string, GraphNode> = {};
|
| 256 |
+
nodes.forEach(n => { m[n.id] = n; });
|
| 257 |
+
return m;
|
| 258 |
}, [nodes]);
|
| 259 |
|
| 260 |
const selectedInfo = selectedNode ? nodeMap[selectedNode] : null;
|
| 261 |
+
const selectedDepth = selectedNode ? reachabilityMap.get(selectedNode) : undefined;
|
| 262 |
const connectedEdges = selectedNode
|
| 263 |
+
? visibleEdges.filter(e => e.source === selectedNode || e.target === selectedNode)
|
| 264 |
: [];
|
| 265 |
|
| 266 |
+
const runLiveQuery = useCallback(async () => {
|
| 267 |
+
if (!liveQuery.trim()) return;
|
| 268 |
+
setLiveLoading(true);
|
| 269 |
+
setLiveError("");
|
| 270 |
+
setLiveGraph(null);
|
| 271 |
+
try {
|
| 272 |
+
const res = await fetch("/api/compare", {
|
| 273 |
+
method: "POST",
|
| 274 |
+
headers: { "Content-Type": "application/json" },
|
| 275 |
+
body: JSON.stringify({ query: liveQuery, provider: "openai", topK: 8 }),
|
| 276 |
+
});
|
| 277 |
+
const data = await res.json();
|
| 278 |
+
const entities: string[] = data.graphrag?.entities ?? [];
|
| 279 |
+
if (entities.length === 0) {
|
| 280 |
+
setLiveError("No entities returned — TigerGraph retrieval returned 0 chunks. Demo mode may be active.");
|
| 281 |
+
} else {
|
| 282 |
+
setLiveGraph(buildLiveGraph(entities, liveQuery));
|
| 283 |
+
}
|
| 284 |
+
} catch {
|
| 285 |
+
setLiveError("Request failed. Check that the dev server is running and an API key is set.");
|
| 286 |
+
}
|
| 287 |
+
setLiveLoading(false);
|
| 288 |
+
}, [liveQuery]);
|
| 289 |
+
|
| 290 |
return (
|
| 291 |
<div>
|
| 292 |
{/* Scenario Selector */}
|
|
|
|
| 294 |
<div className="flex flex-col md:flex-row gap-6 items-start md:items-end">
|
| 295 |
<div className="flex-1">
|
| 296 |
<div className="caption-uppercase mb-2" style={{ color: "var(--color-tiger-orange)" }}>Scenario</div>
|
| 297 |
+
<div className="flex flex-wrap gap-2">
|
| 298 |
{SCENARIOS.map((s, i) => (
|
| 299 |
<button
|
| 300 |
key={i}
|
|
|
|
| 311 |
</div>
|
| 312 |
<div className="flex items-center gap-4">
|
| 313 |
<label className="caption whitespace-nowrap flex items-center gap-2">
|
| 314 |
+
Hops:
|
| 315 |
+
<strong style={{ color: "var(--color-tiger-orange)", fontFamily: "var(--font-mono)", minWidth: "1ch" }}>{hops}</strong>
|
| 316 |
+
<input
|
| 317 |
+
type="range" min={1} max={4} step={1} value={hops}
|
| 318 |
+
onChange={e => { setHops(+e.target.value); setSelectedNode(null); }}
|
| 319 |
+
className="w-24 accent-[#FF6B00]"
|
| 320 |
+
/>
|
| 321 |
</label>
|
| 322 |
+
<span className="badge-outline" style={{ fontSize: "0.6875rem" }}>
|
| 323 |
+
{visibleNodes.length}/{nodes.length} nodes · {visibleEdges.length}/{edges.length} edges
|
| 324 |
+
</span>
|
| 325 |
</div>
|
| 326 |
</div>
|
| 327 |
</div>
|
| 328 |
|
| 329 |
<div className="grid grid-cols-1 xl:grid-cols-4 gap-6">
|
| 330 |
+
{/* Graph SVG — 3 cols */}
|
| 331 |
<div className="xl:col-span-3">
|
| 332 |
<div className="card animate-scale-in" style={{ padding: "16px" }}>
|
| 333 |
+
<svg viewBox="0 0 900 520" style={{ width: "100%", minHeight: "480px" }}>
|
| 334 |
<defs>
|
| 335 |
<filter id="glow">
|
| 336 |
+
<feGaussianBlur stdDeviation="3" result="coloredBlur"/>
|
| 337 |
<feMerge>
|
| 338 |
<feMergeNode in="coloredBlur"/>
|
| 339 |
<feMergeNode in="SourceGraphic"/>
|
| 340 |
</feMerge>
|
| 341 |
</filter>
|
| 342 |
+
<marker id="arrow" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
|
| 343 |
+
<polygon points="0 0, 8 3, 0 6" fill="#c8c3bb" />
|
| 344 |
+
</marker>
|
| 345 |
+
<marker id="arrow-hot" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
|
| 346 |
+
<polygon points="0 0, 8 3, 0 6" fill="#FF6B00" />
|
| 347 |
</marker>
|
| 348 |
</defs>
|
| 349 |
|
| 350 |
+
{/* Dimmed nodes that are hidden by hop filter */}
|
| 351 |
+
{nodes.filter(n => !reachabilityMap.has(n.id)).map(node => (
|
| 352 |
+
<circle key={`dim-${node.id}`} cx={node.x} cy={node.y} r={18}
|
| 353 |
+
fill={TYPE_COLORS[node.type] || "#AED6F1"} opacity={0.08} />
|
| 354 |
+
))}
|
| 355 |
+
|
| 356 |
{/* Edges */}
|
| 357 |
+
{visibleEdges.map((edge, i) => {
|
| 358 |
const s = nodeMap[edge.source];
|
| 359 |
const t = nodeMap[edge.target];
|
| 360 |
if (!s || !t) return null;
|
| 361 |
const isConnected = selectedNode && (edge.source === selectedNode || edge.target === selectedNode);
|
| 362 |
+
const dimmed = selectedNode && !isConnected;
|
| 363 |
const mx = (s.x + t.x) / 2;
|
| 364 |
+
const my = (s.y + t.y) / 2 - 10;
|
| 365 |
return (
|
| 366 |
+
<g key={`edge-${i}`} opacity={dimmed ? 0.12 : 1}>
|
| 367 |
<line
|
| 368 |
x1={s.x} y1={s.y} x2={t.x} y2={t.y}
|
| 369 |
+
stroke={isConnected ? "#FF6B00" : "#d1cdc5"}
|
| 370 |
+
strokeWidth={isConnected ? 2.5 : 1.5}
|
| 371 |
+
markerEnd={isConnected ? "url(#arrow-hot)" : "url(#arrow)"}
|
|
|
|
| 372 |
/>
|
| 373 |
+
<text x={mx} y={my} textAnchor="middle" fontSize="8.5"
|
| 374 |
+
fill={isConnected ? "#FF6B00" : "#9e9990"}
|
| 375 |
+
fontFamily="var(--font-mono)" fontWeight={isConnected ? 600 : 400}>
|
| 376 |
+
{edge.label}
|
| 377 |
+
</text>
|
|
|
|
| 378 |
</g>
|
| 379 |
);
|
| 380 |
})}
|
| 381 |
|
| 382 |
{/* Nodes */}
|
| 383 |
+
{visibleNodes.map(node => {
|
| 384 |
const color = TYPE_COLORS[node.type] || "#AED6F1";
|
| 385 |
const isSelected = selectedNode === node.id;
|
| 386 |
const isConnected = connectedEdges.some(e => e.source === node.id || e.target === node.id);
|
|
|
|
| 387 |
const dimmed = selectedNode && !isSelected && !isConnected;
|
| 388 |
+
const depth = reachabilityMap.get(node.id) ?? 0;
|
| 389 |
+
const r = node.type === "QUERY" ? 26 : isSelected ? 24 : 20;
|
| 390 |
+
|
| 391 |
return (
|
| 392 |
<g
|
| 393 |
key={node.id}
|
| 394 |
+
style={{ cursor: "pointer" }}
|
| 395 |
onClick={() => setSelectedNode(isSelected ? null : node.id)}
|
| 396 |
+
opacity={dimmed ? 0.18 : 1}
|
| 397 |
>
|
| 398 |
+
{/* Glow rings for selected */}
|
| 399 |
{isSelected && (
|
| 400 |
<>
|
| 401 |
+
<circle cx={node.x} cy={node.y} r={r + 14} fill={color} opacity={0.10} />
|
| 402 |
+
<circle cx={node.x} cy={node.y} r={r + 7} fill={color} opacity={0.15} filter="url(#glow)" />
|
|
|
|
| 403 |
</>
|
| 404 |
)}
|
| 405 |
<circle
|
| 406 |
cx={node.x} cy={node.y} r={r}
|
| 407 |
fill={color}
|
| 408 |
+
stroke={isSelected ? "white" : "rgba(255,255,255,0.7)"}
|
| 409 |
+
strokeWidth={isSelected ? 3 : 2}
|
| 410 |
/>
|
| 411 |
+
{/* Hop depth badge (small dot in top-right of node) */}
|
| 412 |
+
{depth > 0 && (
|
| 413 |
+
<text x={node.x + r - 2} y={node.y - r + 6}
|
| 414 |
+
textAnchor="middle" fontSize="8" fill="white"
|
| 415 |
+
fontFamily="var(--font-mono)" fontWeight="700">
|
| 416 |
+
{depth}
|
| 417 |
+
</text>
|
| 418 |
+
)}
|
| 419 |
{node.type === "QUERY" && (
|
| 420 |
+
<text x={node.x} y={node.y + 5} textAnchor="middle"
|
| 421 |
+
fontSize="15" fill="white" fontWeight="bold">?</text>
|
| 422 |
)}
|
| 423 |
<text
|
| 424 |
+
x={node.x} y={node.y + r + 15}
|
| 425 |
textAnchor="middle"
|
| 426 |
+
fontSize={isSelected ? "11.5" : "10"}
|
| 427 |
fontWeight={isSelected ? "600" : "400"}
|
| 428 |
fill="#141413"
|
| 429 |
fontFamily="var(--font-sans)"
|
|
|
|
| 447 |
{selectedInfo ? (
|
| 448 |
<div>
|
| 449 |
<div className="title-lg" style={{ color: "#faf9f5" }}>{selectedInfo.label}</div>
|
| 450 |
+
<div className="flex items-center gap-2 mt-3 flex-wrap">
|
| 451 |
+
<span className="badge" style={{
|
| 452 |
+
background: TYPE_COLORS[selectedInfo.type] || "#AED6F1",
|
| 453 |
+
color: "white", fontSize: "0.6875rem",
|
| 454 |
+
}}>
|
| 455 |
+
{selectedInfo.type}
|
| 456 |
+
</span>
|
| 457 |
+
{selectedDepth !== undefined && (
|
| 458 |
+
<span className="badge-outline" style={{ fontSize: "0.6875rem" }}>
|
| 459 |
+
Hop {selectedDepth} from query
|
| 460 |
+
</span>
|
| 461 |
+
)}
|
| 462 |
+
</div>
|
| 463 |
{selectedInfo.description && (
|
| 464 |
<p className="body-sm mt-3" style={{ color: "#a09d96", lineHeight: 1.6 }}>
|
| 465 |
{selectedInfo.description}
|
|
|
|
| 467 |
)}
|
| 468 |
<div className="mt-4 pt-4" style={{ borderTop: "1px solid rgba(255,255,255,0.08)" }}>
|
| 469 |
<div className="caption mb-2" style={{ color: "#a09d96" }}>
|
| 470 |
+
{connectedEdges.length} visible connection{connectedEdges.length !== 1 ? "s" : ""}
|
| 471 |
</div>
|
| 472 |
{connectedEdges.map((e, i) => {
|
| 473 |
+
const otherId = e.source === selectedInfo.id ? e.target : e.source;
|
| 474 |
+
const otherNode = nodeMap[otherId];
|
| 475 |
+
const dir = e.source === selectedInfo.id ? "→" : "←";
|
| 476 |
return (
|
| 477 |
+
<div key={i} className="flex items-center gap-2 mb-2"
|
| 478 |
+
style={{
|
| 479 |
+
padding: "6px 10px", borderRadius: "8px",
|
| 480 |
+
background: "rgba(255,255,255,0.04)", cursor: "pointer",
|
| 481 |
+
}}
|
| 482 |
+
onClick={() => setSelectedNode(otherId)}>
|
| 483 |
+
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{
|
| 484 |
background: TYPE_COLORS[otherNode?.type ?? ""] || "#AED6F1",
|
| 485 |
}} />
|
| 486 |
+
<span style={{ color: "#FF6B00", fontFamily: "var(--font-mono)", fontSize: "0.7rem" }}>
|
| 487 |
+
{dir} {e.label}
|
| 488 |
</span>
|
| 489 |
+
<span style={{ color: "#faf9f5", fontSize: "0.8rem" }}>
|
|
|
|
| 490 |
{otherNode?.label}
|
| 491 |
</span>
|
| 492 |
</div>
|
|
|
|
| 496 |
</div>
|
| 497 |
) : (
|
| 498 |
<p className="body-sm" style={{ color: "#a09d96" }}>
|
| 499 |
+
Click any visible node to inspect its entity type, hop distance from the query, and connections.
|
| 500 |
+
Use the hops slider to see the graph expand step by step.
|
| 501 |
</p>
|
| 502 |
)}
|
| 503 |
</div>
|
|
|
|
| 506 |
<div className="card-cream animate-fade-in-up delay-200">
|
| 507 |
<div className="caption-uppercase mb-3">Graph Statistics</div>
|
| 508 |
{[
|
| 509 |
+
{ label: "Visible Nodes", value: `${visibleNodes.length} / ${nodes.length}`, color: "#FF6B00" },
|
| 510 |
+
{ label: "Visible Edges", value: `${visibleEdges.length} / ${edges.length}`, color: "#0072CE" },
|
| 511 |
+
{ label: "Max Hops", value: hops, color: "#5db8a6" },
|
| 512 |
+
{ label: "Avg Degree", value: visibleNodes.length > 0 ? (visibleEdges.length * 2 / visibleNodes.length).toFixed(1) : "0", color: "#cc785c" },
|
| 513 |
+
{ label: "Entity Types", value: new Set(visibleNodes.map(n => n.type)).size, color: "#002B49" },
|
| 514 |
].map((s, i) => (
|
| 515 |
<div key={i} className="flex justify-between items-center py-2.5"
|
| 516 |
style={{ borderBottom: i < 4 ? "1px solid var(--color-hairline-soft)" : "none" }}>
|
|
|
|
| 531 |
</div>
|
| 532 |
))}
|
| 533 |
</div>
|
| 534 |
+
<p className="body-sm mt-3" style={{ color: "var(--color-muted)", fontSize: "0.7rem" }}>
|
| 535 |
+
Small number on each node = hop distance from query node.
|
| 536 |
+
</p>
|
| 537 |
</div>
|
| 538 |
</div>
|
| 539 |
</div>
|
|
|
|
| 542 |
<div className="card mt-8 animate-fade-in-up delay-400">
|
| 543 |
<div className="title-lg mb-6">🧠 Graph Reasoning Path</div>
|
| 544 |
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
| 545 |
+
{reasoning.map((step, i) => {
|
| 546 |
+
const active = i < hops;
|
| 547 |
+
return (
|
| 548 |
+
<div key={i} className={active ? "card" : "card"} style={{
|
| 549 |
+
padding: "20px", position: "relative",
|
| 550 |
+
opacity: active ? 1 : 0.38,
|
| 551 |
+
borderLeft: active ? `3px solid var(--color-tiger-orange)` : undefined,
|
| 552 |
+
transition: "opacity 0.25s ease",
|
| 553 |
}}>
|
| 554 |
+
<div style={{
|
| 555 |
+
position: "absolute", top: "12px", right: "12px",
|
| 556 |
+
fontFamily: "var(--font-mono)", fontSize: "0.6875rem",
|
| 557 |
+
color: active ? "var(--color-tiger-orange)" : "var(--color-muted)", fontWeight: 600,
|
| 558 |
+
}}>
|
| 559 |
+
Step {i + 1}
|
| 560 |
+
</div>
|
| 561 |
+
<p className="body-sm" style={{ color: "var(--color-body)", lineHeight: 1.6 }}>
|
| 562 |
+
{step}
|
| 563 |
+
</p>
|
| 564 |
</div>
|
| 565 |
+
);
|
| 566 |
+
})}
|
| 567 |
+
</div>
|
| 568 |
+
<p className="body-sm mt-4" style={{ color: "var(--color-muted)", fontStyle: "italic" }}>
|
| 569 |
+
Steps highlight based on the current hop depth. Drag the slider above to walk through the reasoning.
|
| 570 |
+
</p>
|
| 571 |
+
</div>
|
| 572 |
+
|
| 573 |
+
{/* Live Query Section */}
|
| 574 |
+
<div className="card mt-8 animate-fade-in-up">
|
| 575 |
+
<div className="title-lg mb-2">🔴 Live Entity Query</div>
|
| 576 |
+
<p className="body-sm mb-6" style={{ color: "var(--color-muted)" }}>
|
| 577 |
+
Ask a science question. GraphRAG retrieves entities from TigerGraph and renders them as a live graph.
|
| 578 |
+
Requires <code>OPENAI_API_KEY</code> and <code>TG_HOST</code> in <code>web/.env</code>.
|
| 579 |
+
</p>
|
| 580 |
+
<div className="flex gap-3 mb-5">
|
| 581 |
+
<input
|
| 582 |
+
className="input flex-1"
|
| 583 |
+
placeholder="e.g. What is the relationship between DNA and proteins?"
|
| 584 |
+
value={liveQuery}
|
| 585 |
+
onChange={e => setLiveQuery(e.target.value)}
|
| 586 |
+
onKeyDown={e => { if (e.key === "Enter") runLiveQuery(); }}
|
| 587 |
+
/>
|
| 588 |
+
<button className="btn btn-primary" onClick={runLiveQuery}
|
| 589 |
+
disabled={liveLoading || !liveQuery.trim()}>
|
| 590 |
+
{liveLoading ? (
|
| 591 |
+
<span className="flex items-center gap-2">
|
| 592 |
+
<span className="animate-spin inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full" />
|
| 593 |
+
Querying…
|
| 594 |
+
</span>
|
| 595 |
+
) : "Run Live"}
|
| 596 |
+
</button>
|
| 597 |
</div>
|
| 598 |
+
|
| 599 |
+
{liveError && (
|
| 600 |
+
<div className="card-cream mb-4" style={{ padding: "12px 16px", borderLeft: "3px solid #e17055" }}>
|
| 601 |
+
<span className="body-sm" style={{ color: "#d63031" }}>{liveError}</span>
|
| 602 |
+
</div>
|
| 603 |
+
)}
|
| 604 |
+
|
| 605 |
+
{liveGraph && (
|
| 606 |
+
<div>
|
| 607 |
+
<div className="caption mb-3" style={{ color: "var(--color-muted)" }}>
|
| 608 |
+
{liveGraph.nodes.length - 1} entities retrieved from TigerGraph — star topology (query → entities, hop 1)
|
| 609 |
+
</div>
|
| 610 |
+
<div className="card" style={{ padding: "16px" }}>
|
| 611 |
+
<svg viewBox="0 0 900 500" style={{ width: "100%", minHeight: "400px" }}>
|
| 612 |
+
<defs>
|
| 613 |
+
<marker id="arrow-live" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
|
| 614 |
+
<polygon points="0 0, 8 3, 0 6" fill="#FF6B00" />
|
| 615 |
+
</marker>
|
| 616 |
+
</defs>
|
| 617 |
+
{liveGraph.edges.map((e, i) => {
|
| 618 |
+
const s = liveGraph.nodes.find(n => n.id === e.source);
|
| 619 |
+
const t = liveGraph.nodes.find(n => n.id === e.target);
|
| 620 |
+
if (!s || !t) return null;
|
| 621 |
+
return (
|
| 622 |
+
<line key={i} x1={s.x} y1={s.y} x2={t.x} y2={t.y}
|
| 623 |
+
stroke="#FF6B00" strokeWidth="1.5" strokeOpacity={0.5}
|
| 624 |
+
markerEnd="url(#arrow-live)" />
|
| 625 |
+
);
|
| 626 |
+
})}
|
| 627 |
+
{liveGraph.nodes.map((node, i) => {
|
| 628 |
+
const isQuery = node.id === "q";
|
| 629 |
+
const color = isQuery ? "#FF6B00" : "#AED6F1";
|
| 630 |
+
const r = isQuery ? 26 : 20;
|
| 631 |
+
return (
|
| 632 |
+
<g key={i}>
|
| 633 |
+
<circle cx={node.x} cy={node.y} r={r} fill={color}
|
| 634 |
+
stroke="white" strokeWidth="2.5" />
|
| 635 |
+
{isQuery && (
|
| 636 |
+
<text x={node.x} y={node.y + 5} textAnchor="middle"
|
| 637 |
+
fontSize="14" fill="white" fontWeight="bold">?</text>
|
| 638 |
+
)}
|
| 639 |
+
<foreignObject x={node.x - 60} y={node.y + r + 6} width="120" height="40">
|
| 640 |
+
<div style={{
|
| 641 |
+
fontSize: "9.5px", textAlign: "center", color: "#141413",
|
| 642 |
+
lineHeight: 1.35, wordBreak: "break-word",
|
| 643 |
+
fontFamily: "var(--font-sans)",
|
| 644 |
+
}}>
|
| 645 |
+
{node.label}
|
| 646 |
+
</div>
|
| 647 |
+
</foreignObject>
|
| 648 |
+
</g>
|
| 649 |
+
);
|
| 650 |
+
})}
|
| 651 |
+
</svg>
|
| 652 |
+
</div>
|
| 653 |
+
</div>
|
| 654 |
+
)}
|
| 655 |
</div>
|
| 656 |
</div>
|
| 657 |
);
|