muthuk1 commited on
Commit
b40d0b7
·
1 Parent(s): f26d060

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
- const TYPE_COLORS: Record<string, string> = {
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: "Nationality Comparison",
40
- query: "Were Scott Derrickson and Ed Wood of the same nationality?",
41
  nodes: [
42
- { id: "q", label: "Query", type: "QUERY", x: 450, y: 50, description: "Bridge question comparing two entities" },
43
- { id: "sd", label: "Scott Derrickson", type: "PERSON", x: 200, y: 190, description: "American filmmaker, director of Sinister (2012)" },
44
- { id: "ew", label: "Ed Wood", type: "PERSON", x: 700, y: 190, description: "American filmmaker, director of Plan 9 from Outer Space" },
45
- { id: "us", label: "United States", type: "LOCATION", x: 450, y: 340, description: "Countryshared nationality node" },
46
- { id: "denver", label: "Denver, CO", type: "LOCATION", x: 120, y: 340, description: "Birthplace of Scott Derrickson" },
47
- { id: "pough", label: "Poughkeepsie, NY", type: "LOCATION", x: 780, y: 340, description: "Birthplace of Ed Wood" },
48
- { id: "sinister", label: "Sinister (2012)", type: "WORK", x: 100, y: 200, description: "Horror film directed by Derrickson" },
49
- { id: "planNine", label: "Plan 9 from Outer Space", type: "WORK", x: 800, y: 200, description: "Cult classic by Ed Wood" },
50
- { id: "horror", label: "Horror Genre", type: "CONCEPT", x: 450, y: 460, description: "Shared genre concept" },
 
51
  ],
52
  edges: [
53
- { source: "q", target: "sd", label: "FOUND_ENTITY" },
54
- { source: "q", target: "ew", label: "FOUND_ENTITY" },
55
- { source: "sd", target: "denver", label: "BORN_IN" },
56
- { source: "ew", target: "pough", label: "BORN_IN" },
57
- { source: "denver", target: "us", label: "LOCATED_IN" },
58
- { source: "pough", target: "us", label: "LOCATED_IN" },
59
- { source: "sd", target: "sinister", label: "DIRECTED" },
60
- { source: "ew", target: "planNine", label: "DIRECTED" },
61
- { source: "sinister", target: "horror", label: "GENRE" },
62
- { source: "planNine", target: "horror", label: "GENRE" },
63
  ],
64
  reasoning: [
65
- "Entry: Query identifies two key entities Scott Derrickson and Ed Wood",
66
- "Hop 1: BORN_IN relationships Derrickson Denver, CO; Wood Poughkeepsie, NY",
67
- "Hop 2: LOCATED_IN traversal Both cities United States",
68
- "Convergence: Both paths meet at 'United States' node same nationality confirmed",
69
  ],
70
  },
71
  {
72
- name: "Magazine Comparison",
73
- query: "Which magazine was started first, Arthur's Magazine or First for Women?",
74
  nodes: [
75
- { id: "q", label: "Query", type: "QUERY", x: 450, y: 50, description: "Comparison questiontemporal ordering" },
76
- { id: "am", label: "Arthur's Magazine", type: "WORK", x: 220, y: 200, description: "American literary periodical" },
77
- { id: "fw", label: "First for Women", type: "WORK", x: 680, y: 200, description: "American women's magazine" },
78
- { id: "d1", label: "1844", type: "DATE", x: 220, y: 350, description: "Year Arthur's Magazine was founded" },
79
- { id: "d2", label: "1989", type: "DATE", x: 680, y: 350, description: "Year First for Women was founded" },
80
- { id: "pub", label: "Publishing", type: "CONCEPT", x: 450, y: 280, description: "Industry category" },
81
- { id: "usa", label: "United States", type: "LOCATION", x: 450, y: 420, description: "Country of publication" },
 
 
 
82
  ],
83
  edges: [
84
- { source: "q", target: "am", label: "FOUND_ENTITY" },
85
- { source: "q", target: "fw", label: "FOUND_ENTITY" },
86
- { source: "am", target: "d1", label: "FOUNDED_IN" },
87
- { source: "fw", target: "d2", label: "FOUNDED_IN" },
88
- { source: "am", target: "pub", label: "INDUSTRY" },
89
- { source: "fw", target: "pub", label: "INDUSTRY" },
90
- { source: "am", target: "usa", label: "PUBLISHED_IN" },
91
- { source: "fw", target: "usa", label: "PUBLISHED_IN" },
 
 
92
  ],
93
  reasoning: [
94
- "Entry: Two entities identified Arthur's Magazine, First for Women",
95
- "Hop 1: FOUNDED_IN datesArthur's 1844; First for Women → 1989",
96
- "Comparison: 1844 < 1989 Arthur's Magazine predates by 145 years",
97
- "Answer: Arthur's Magazine was started first",
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(2);
 
 
 
 
 
 
106
 
107
  const scenario = SCENARIOS[scenarioIdx];
108
  const { nodes, edges, reasoning } = scenario;
109
 
 
 
 
 
 
 
 
 
 
 
 
110
  const nodeMap = useMemo(() => {
111
- const map: Record<string, GraphNode> = {};
112
- nodes.forEach((n) => { map[n.id] = n; });
113
- return map;
114
  }, [nodes]);
115
 
116
  const selectedInfo = selectedNode ? nodeMap[selectedNode] : null;
117
-
118
  const connectedEdges = selectedNode
119
- ? edges.filter(e => e.source === selectedNode || e.target === selectedNode)
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-3">
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: <strong style={{ color: "var(--color-tiger-orange)", fontFamily: "var(--font-mono)" }}>{hops}</strong>
147
- <input type="range" min={1} max={4} step={1} value={hops}
148
- onChange={(e) => setHops(+e.target.value)}
149
- className="w-24 accent-[#FF6B00]" />
 
 
 
150
  </label>
 
 
 
151
  </div>
152
  </div>
153
  </div>
154
 
155
  <div className="grid grid-cols-1 xl:grid-cols-4 gap-6">
156
- {/* Graph Visualization — 3 cols */}
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%", height: "100%", minHeight: "480px" }}>
160
  <defs>
161
  <filter id="glow">
162
- <feGaussianBlur stdDeviation="4" result="coloredBlur"/>
163
  <feMerge>
164
  <feMergeNode in="coloredBlur"/>
165
  <feMergeNode in="SourceGraphic"/>
166
  </feMerge>
167
  </filter>
168
- <marker id="arrowhead" markerWidth="10" markerHeight="7" refX="10" refY="3.5" orient="auto">
169
- <polygon points="0 0, 10 3.5, 0 7" fill="#e6dfd8" />
 
 
 
170
  </marker>
171
  </defs>
172
 
 
 
 
 
 
 
173
  {/* Edges */}
174
- {edges.map((edge, i) => {
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 isHighlighted = !selectedNode || isConnected;
180
  const mx = (s.x + t.x) / 2;
181
- const my = (s.y + t.y) / 2 - 8;
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" : "#e6dfd8"}
187
- strokeWidth={isConnected ? 3 : 1.5}
188
- strokeOpacity={isHighlighted ? 0.85 : 0.2}
189
- markerEnd={isConnected ? undefined : "url(#arrowhead)"}
190
  />
191
- {isHighlighted && (
192
- <text x={mx} y={my} textAnchor="middle" fontSize="9" fill={isConnected ? "#FF6B00" : "#8e8b82"}
193
- fontFamily="var(--font-mono)" fontWeight={isConnected ? 600 : 400}>
194
- {edge.label}
195
- </text>
196
- )}
197
  </g>
198
  );
199
  })}
200
 
201
  {/* Nodes */}
202
- {nodes.map((node) => {
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
- className="graph-node cursor-pointer"
212
  onClick={() => setSelectedNode(isSelected ? null : node.id)}
213
- opacity={dimmed ? 0.3 : 1}
214
  >
 
215
  {isSelected && (
216
  <>
217
- <circle cx={node.x} cy={node.y} r={r + 12} fill={color} opacity={0.12} />
218
- <circle cx={node.x} cy={node.y} r={r + 6} fill={color} opacity={0.08}
219
- filter="url(#glow)" />
220
  </>
221
  )}
222
  <circle
223
  cx={node.x} cy={node.y} r={r}
224
  fill={color}
225
- stroke="white" strokeWidth="3"
 
226
  />
 
 
 
 
 
 
 
 
227
  {node.type === "QUERY" && (
228
- <text x={node.x} y={node.y + 4} textAnchor="middle"
229
- fontSize="14" fill="white" fontWeight="bold">?</text>
230
  )}
231
  <text
232
- x={node.x} y={node.y + r + 16}
233
  textAnchor="middle"
234
- fontSize={isSelected ? "12" : "10.5"}
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
- <span className="badge mt-3 inline-block" style={{
259
- background: TYPE_COLORS[selectedInfo.type] || "#AED6F1",
260
- color: "white", fontSize: "0.6875rem",
261
- }}>
262
- {selectedInfo.type}
263
- </span>
 
 
 
 
 
 
 
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} Connection{connectedEdges.length !== 1 ? "s" : ""}
272
  </div>
273
  {connectedEdges.map((e, i) => {
274
- const other = e.source === selectedInfo.id ? e.target : e.source;
275
- const otherNode = nodeMap[other];
 
276
  return (
277
- <div key={i} className="flex items-center gap-2 mb-2 cursor-pointer"
278
- onClick={() => setSelectedNode(other)}
279
- style={{ padding: "6px 10px", borderRadius: "8px", background: "rgba(255,255,255,0.04)" }}>
280
- <div className="w-2.5 h-2.5 rounded-full" style={{
 
 
 
281
  background: TYPE_COLORS[otherNode?.type ?? ""] || "#AED6F1",
282
  }} />
283
- <span style={{ color: "#faf9f5", fontFamily: "var(--font-mono)", fontSize: "0.75rem" }}>
284
- {e.label}
285
  </span>
286
- <span style={{ color: "#a09d96", fontSize: "0.75rem" }}>→</span>
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 on the graph to see its details, connections, and type information.
 
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: "Avg Degree", value: (edges.length * 2 / nodes.length).toFixed(1), color: "#5db8a6" },
309
- { label: "Entity Types", value: new Set(nodes.map(n => n.type)).size, color: "#cc785c" },
310
- { label: "Hops", value: hops, color: "#002B49" },
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
- <div key={i} className="card-cream" style={{ padding: "20px", position: "relative" }}>
341
- <div style={{
342
- position: "absolute", top: "12px", right: "12px",
343
- fontFamily: "var(--font-mono)", fontSize: "0.6875rem",
344
- color: "var(--color-tiger-orange)", fontWeight: 600,
 
 
345
  }}>
346
- Step {i + 1}
 
 
 
 
 
 
 
 
 
347
  </div>
348
- <p className="body-sm" style={{ color: "var(--color-body)", lineHeight: 1.6 }}>
349
- {step}
350
- </p>
351
- </div>
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_BYlight energy absorbed by chlorophyll in thylakoids",
139
+ "Hop 2: INCLUDESphotosynthesis 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 comparisonidentify 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 edgesPlanck, Bohr, Heisenberg, Schrödinger each contributed",
173
+ "Hop 2: Specific contributionsUncertainty 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
  );