muthuk1's picture
Explorer live query: better error, science chips, fallback answer
64c3622
"use client";
import { useState, useMemo, useCallback } from "react";
interface GraphNode {
id: string;
label: string;
type: string;
x: number;
y: number;
description?: string;
}
interface GraphEdge {
source: string;
target: string;
label: string;
}
interface Scenario {
name: string;
query: string;
nodes: GraphNode[];
edges: GraphEdge[];
reasoning: string[];
}
const TYPE_COLORS: Record<string, string> = {
PERSON: "#FF6B6B",
ORGANIZATION: "#4ECDC4",
LOCATION: "#45B7D1",
MOLECULE: "#A29BFE",
ORGANELLE: "#55EFC4",
CONCEPT: "#AED6F1",
PROCESS: "#F9CA24",
CONSTANT: "#FD79A8",
ELEMENT: "#74B9FF",
QUERY: "#FF6B00",
};
// ── 4 Science Scenarios ────────────────────────────────────────────────────
const SCENARIOS: Scenario[] = [
{
name: "General Relativity",
query: "How does General Relativity predict gravitational waves?",
nodes: [
{ id: "q", label: "Query", type: "QUERY", x: 450, y: 260, description: "Entry point β€” identify key entities and traverse the graph" },
{ id: "einstein", label: "Albert Einstein", type: "PERSON", x: 200, y: 120, description: "Theoretical physicist; developed General Relativity (1915)" },
{ id: "gr", label: "General Relativity", type: "CONCEPT", x: 680, y: 120, description: "Geometric theory of gravitation; gravity = spacetime curvature" },
{ id: "spacetime", label: "Spacetime Curvature",type: "CONCEPT", x: 450, y: 90, description: "4D manifold warped by mass and energy β€” the mechanism of gravity" },
{ id: "grav_waves", label: "Gravitational Waves",type: "CONCEPT", x: 790, y: 270, description: "Ripples in spacetime produced by accelerating masses; predicted 1916" },
{ id: "black_holes",label: "Black Holes", type: "CONCEPT", x: 700, y: 420, description: "Regions where spacetime curvature prevents light escape; GR prediction" },
{ id: "ligo", label: "LIGO Detector", type: "ORGANIZATION", x: 450, y: 440, description: "Detected gravitational waves 14 Sep 2015 β€” confirmed GR's prediction" },
{ id: "eddington", label: "Eddington (1919)", type: "PERSON", x: 160, y: 320, description: "Observed light bending around the Sun during 1919 eclipse β€” first GR proof" },
{ id: "gps", label: "GPS Satellites", type: "CONCEPT", x: 160, y: 430, description: "Require GR time-dilation corrections; practical proof of the theory" },
],
edges: [
{ source: "q", target: "einstein", label: "FOUND_ENTITY" },
{ source: "q", target: "gr", label: "FOUND_ENTITY" },
{ source: "einstein", target: "gr", label: "DEVELOPED_1915" },
{ source: "einstein", target: "spacetime", label: "PROPOSED" },
{ source: "gr", target: "spacetime", label: "DESCRIBES" },
{ source: "gr", target: "grav_waves", label: "PREDICTS" },
{ source: "gr", target: "black_holes",label: "PREDICTS" },
{ source: "grav_waves", target: "ligo", label: "DETECTED_BY" },
{ source: "eddington", target: "gr", label: "CONFIRMED_1919" },
{ source: "gr", target: "gps", label: "CORRECTION_REQUIRED_BY" },
],
reasoning: [
"Entry: Query identifies Einstein and General Relativity as key entities",
"Hop 1: DEVELOPED_1915 β†’ Einstein proposed spacetime curvature as gravity",
"Hop 2: PREDICTS edges β†’ GR implies gravitational waves and black holes",
"Hop 3: DETECTED_BY β†’ LIGO confirmed waves 100 years after prediction",
],
},
{
name: "DNA β†’ Protein",
query: "How does DNA encode and produce proteins?",
nodes: [
{ id: "q", label: "Query", type: "QUERY", x: 450, y: 260, description: "Multi-hop biology question β€” trace the central dogma pathway" },
{ id: "dna", label: "DNA", type: "MOLECULE", x: 200, y: 130, description: "Double-helix polymer; stores genetic instructions via A-T-G-C base pairs" },
{ id: "rna", label: "mRNA", type: "MOLECULE", x: 700, y: 130, description: "Messenger RNA; transcribed copy of a gene, carries code to ribosome" },
{ id: "protein", label: "Protein", type: "MOLECULE", x: 450, y: 420, description: "Amino-acid chain folded into functional shape; performs cellular work" },
{ 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" },
{ id: "helicase", label: "Helicase", type: "MOLECULE", x: 200, y: 370, description: "Enzyme that unwinds the DNA double helix during replication/transcription" },
{ id: "ribosome", label: "Ribosome", type: "ORGANELLE",x: 700, y: 370, description: "Molecular machine that reads mRNA codons and assembles amino acids into protein" },
{ id: "nucleus", label: "Cell Nucleus", type: "ORGANELLE",x: 200, y: 260, description: "DNA is stored here; transcription (DNA→mRNA) occurs inside" },
{ id: "central_dogma",label: "Central Dogma", type: "CONCEPT", x: 450, y: 110, description: "Information flow: DNA β†’ RNA β†’ Protein (Crick, 1958)" },
],
edges: [
{ source: "q", target: "dna", label: "FOUND_ENTITY" },
{ source: "q", target: "protein", label: "FOUND_ENTITY" },
{ source: "watson_crick", target: "dna", label: "DISCOVERED_1953" },
{ source: "dna", target: "central_dogma", label: "DESCRIBED_BY" },
{ source: "dna", target: "nucleus", label: "LOCATED_IN" },
{ source: "helicase", target: "dna", label: "UNWINDS" },
{ source: "dna", target: "rna", label: "TRANSCRIBED_TO" },
{ source: "rna", target: "ribosome", label: "READ_BY" },
{ source: "ribosome", target: "protein", label: "PRODUCES" },
{ source: "central_dogma",target: "rna", label: "INCLUDES" },
],
reasoning: [
"Entry: Two key entities β€” DNA (information store) and Protein (output)",
"Hop 1: TRANSCRIBED_TO β€” DNA β†’ mRNA; helicase unwinds the double helix",
"Hop 2: READ_BY β€” mRNA travels to ribosome in the cytoplasm",
"Hop 3: PRODUCES β€” Ribosome assembles amino acids into the final protein",
],
},
{
name: "Photosynthesis",
query: "What converts sunlight to glucose in plants?",
nodes: [
{ id: "q", label: "Query", type: "QUERY", x: 450, y: 260, description: "Factoid + multi-hop: identify the process and trace its pathway" },
{ id: "photosynthesis",label:"Photosynthesis", type: "PROCESS", x: 450, y: 110, description: "Converts light energy + COβ‚‚ + Hβ‚‚O β†’ glucose + Oβ‚‚; primary energy source for life" },
{ id: "chlorophyll", label: "Chlorophyll", type: "MOLECULE",x: 200, y: 140, description: "Green pigment in chloroplasts; absorbs red (~680 nm) and blue (~430 nm) light" },
{ id: "light", label: "Light Energy", type: "CONCEPT", x: 80, y: 260, description: "Solar radiation β€” the energy input that drives the entire process" },
{ 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" },
{ 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" },
{ id: "co2", label: "COβ‚‚", type: "MOLECULE",x: 450, y: 430, description: "Carbon dioxide; fixed by RuBisCO enzyme in the Calvin Cycle" },
{ id: "water", label: "Hβ‚‚O", type: "MOLECULE",x: 200, y: 370, description: "Split by photolysis in thylakoids; provides electrons and releases Oβ‚‚" },
{ id: "oxygen", label: "Oβ‚‚ (byproduct)", type: "MOLECULE",x: 80, y: 400, description: "Released during photolysis of water β€” the origin of Earth's atmospheric oxygen" },
{ id: "thylakoid", label: "Thylakoid", type: "ORGANELLE",x:350, y: 370, description: "Membrane system inside chloroplast; site of light-dependent reactions" },
],
edges: [
{ source: "q", target: "photosynthesis",label: "FOUND_ENTITY" },
{ source: "q", target: "chlorophyll", label: "FOUND_ENTITY" },
{ source: "light", target: "chlorophyll", label: "ABSORBED_BY" },
{ source: "chlorophyll", target: "photosynthesis",label: "DRIVES" },
{ source: "water", target: "photosynthesis",label: "INPUT" },
{ source: "water", target: "oxygen", label: "PHOTOLYSIS_PRODUCES" },
{ source: "co2", target: "calvin_cycle", label: "FIXED_BY" },
{ source: "photosynthesis",target: "calvin_cycle", label: "INCLUDES" },
{ source: "calvin_cycle", target: "glucose", label: "PRODUCES" },
{ source: "thylakoid", target: "photosynthesis",label: "LOCATION_OF" },
],
reasoning: [
"Entry: Photosynthesis and Chlorophyll identified as primary entities",
"Hop 1: ABSORBED_BY β€” light energy absorbed by chlorophyll in thylakoids",
"Hop 2: INCLUDES β€” photosynthesis triggers Calvin Cycle with COβ‚‚ as input",
"Hop 3: PRODUCES β€” Calvin Cycle outputs glucose; water photolysis releases Oβ‚‚",
],
},
{
name: "Quantum Mechanics Founders",
query: "Which physicists developed quantum mechanics and what did each contribute?",
nodes: [
{ id: "q", label: "Query", type: "QUERY", x: 450, y: 260, description: "Multi-hop comparison β€” identify multiple entities and their relationships" },
{ 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" },
{ id: "bohr", label: "Niels Bohr", type: "PERSON", x: 180, y: 150, description: "Proposed quantized electron orbits (1913 Bohr model); founded Copenhagen interpretation" },
{ id: "heisenberg", label: "Heisenberg", type: "PERSON", x: 720, y: 150, description: "Formulated matrix mechanics (1925) and the uncertainty principle (1927)" },
{ id: "schrodinger", label: "Schrâdinger", type: "PERSON", x: 180, y: 380, description: "Developed wave mechanics (1926); wave function ψ describes quantum state" },
{ id: "planck", label: "Max Planck", type: "PERSON", x: 720, y: 380, description: "Introduced energy quanta E=hf (1900) to explain blackbody radiation β€” started QM" },
{ id: "uncertainty", label: "Uncertainty Principle",type: "CONCEPT", x: 820, y: 260, description: "Ξ”xΞ”p β‰₯ ℏ/2 β€” position and momentum cannot both be precisely known simultaneously" },
{ id: "wave_fn", label: "Wave Function ψ", type: "CONCEPT", x: 80, y: 260, description: "Mathematical description of quantum state; |ψ|² gives probability density" },
{ id: "atom_model", label: "Bohr Atom Model", type: "CONCEPT", x: 80, y: 110, description: "Quantized electron energy levels; explained hydrogen emission spectrum (1913)" },
{ 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" },
],
edges: [
{ source: "q", target: "qm", label: "FOUND_ENTITY" },
{ source: "q", target: "bohr", label: "FOUND_ENTITY" },
{ source: "planck", target: "qm", label: "FOUNDED_1900" },
{ source: "bohr", target: "qm", label: "DEVELOPED" },
{ source: "heisenberg", target: "qm", label: "DEVELOPED" },
{ source: "schrodinger", target: "qm", label: "DEVELOPED" },
{ source: "heisenberg", target: "uncertainty", label: "FORMULATED_1927" },
{ source: "schrodinger", target: "wave_fn", label: "PROPOSED_1926" },
{ source: "bohr", target: "atom_model", label: "PROPOSED_1913" },
{ source: "planck", target: "photoelectric", label: "QUANTA_EXPLAIN" },
],
reasoning: [
"Entry: Quantum Mechanics identified; four physicist entities extracted",
"Hop 1: FOUNDED/DEVELOPED edges β€” Planck, Bohr, Heisenberg, SchrΓΆdinger each contributed",
"Hop 2: Specific contributions β€” Uncertainty Principle, Wave Function, Bohr Atom",
"Convergence: All four paths meet at Quantum Mechanics β€” multi-founder answer confirmed",
],
},
];
// ── BFS hop reachability ───────────────────────────────────────────────────
function computeReachability(
nodes: GraphNode[],
edges: GraphEdge[],
maxHops: number,
): Map<string, number> {
const queryNode = nodes.find(n => n.type === "QUERY");
if (!queryNode) return new Map(nodes.map(n => [n.id, 0]));
const depths = new Map<string, number>();
const queue: { id: string; depth: number }[] = [{ id: queryNode.id, depth: 0 }];
while (queue.length > 0) {
const { id, depth } = queue.shift()!;
if (depths.has(id)) continue;
depths.set(id, depth);
if (depth < maxHops) {
for (const e of edges) {
if (e.source === id && !depths.has(e.target)) queue.push({ id: e.target, depth: depth + 1 });
if (e.target === id && !depths.has(e.source)) queue.push({ id: e.source, depth: depth + 1 });
}
}
}
return depths;
}
// ── Live query state ───────────────────────────────────────────────────────
interface LiveNode { id: string; label: string; x: number; y: number; hop: number }
interface LiveEdge { source: string; target: string }
function buildLiveGraph(entities: string[], query: string): { nodes: LiveNode[]; edges: LiveEdge[] } {
const cx = 450, cy = 240;
const nodes: LiveNode[] = [{ id: "q", label: query.slice(0, 32) + "…", x: cx, y: cy, hop: 0 }];
const edges: LiveEdge[] = [];
const r = 170;
entities.slice(0, 8).forEach((e, i) => {
const angle = (2 * Math.PI * i) / Math.min(entities.length, 8) - Math.PI / 2;
nodes.push({
id: `e${i}`,
label: e.slice(0, 30),
x: Math.round(cx + r * Math.cos(angle)),
y: Math.round(cy + r * Math.sin(angle)),
hop: 1,
});
edges.push({ source: "q", target: `e${i}` });
});
return { nodes, edges };
}
export function ExplorerContent() {
const [scenarioIdx, setScenarioIdx] = useState(0);
const [selectedNode, setSelectedNode] = useState<string | null>(null);
const [hops, setHops] = useState(3);
// Live query state
const [liveQuery, setLiveQuery] = useState("");
const [liveLoading, setLiveLoading] = useState(false);
const [liveGraph, setLiveGraph] = useState<{ nodes: LiveNode[]; edges: LiveEdge[] } | null>(null);
const [liveError, setLiveError] = useState("");
const scenario = SCENARIOS[scenarioIdx];
const { nodes, edges, reasoning } = scenario;
// BFS hop filter β€” what actually changes when the slider moves
const reachabilityMap = useMemo(
() => computeReachability(nodes, edges, hops),
[nodes, edges, hops],
);
const visibleNodes = useMemo(() => nodes.filter(n => reachabilityMap.has(n.id)), [nodes, reachabilityMap]);
const visibleEdges = useMemo(
() => edges.filter(e => reachabilityMap.has(e.source) && reachabilityMap.has(e.target)),
[edges, reachabilityMap],
);
const nodeMap = useMemo(() => {
const m: Record<string, GraphNode> = {};
nodes.forEach(n => { m[n.id] = n; });
return m;
}, [nodes]);
const selectedInfo = selectedNode ? nodeMap[selectedNode] : null;
const selectedDepth = selectedNode ? reachabilityMap.get(selectedNode) : undefined;
const connectedEdges = selectedNode
? visibleEdges.filter(e => e.source === selectedNode || e.target === selectedNode)
: [];
const [liveAnswer, setLiveAnswer] = useState<string | null>(null);
const runLiveQuery = useCallback(async () => {
if (!liveQuery.trim()) return;
setLiveLoading(true);
setLiveError("");
setLiveGraph(null);
setLiveAnswer(null);
try {
const res = await fetch("/api/compare", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: liveQuery, provider: "openai", topK: 8 }),
});
const data = await res.json();
const entities: string[] = data.graphrag?.entities ?? [];
const answer: string = data.graphrag?.answer ?? "";
if (entities.length === 0) {
setLiveAnswer(answer || null);
setLiveError(
"No graph entities found for this query β€” the Wikipedia science corpus covers physics, biology, " +
"chemistry, and astronomy. Try one of the example questions below."
);
} else {
setLiveGraph(buildLiveGraph(entities, liveQuery));
}
} catch {
setLiveError("Request failed β€” check that the dev server is running and OPENAI_API_KEY is set in web/.env.");
}
setLiveLoading(false);
}, [liveQuery]);
return (
<div>
{/* Scenario Selector */}
<div className="card mb-6 animate-fade-in-up">
<div className="flex flex-col md:flex-row gap-6 items-start md:items-end">
<div className="flex-1">
<div className="caption-uppercase mb-2" style={{ color: "var(--color-tiger-orange)" }}>Scenario</div>
<div className="flex flex-wrap gap-2">
{SCENARIOS.map((s, i) => (
<button
key={i}
className={`btn ${i === scenarioIdx ? "btn-primary" : "btn-secondary"} btn-sm`}
onClick={() => { setScenarioIdx(i); setSelectedNode(null); }}
>
{s.name}
</button>
))}
</div>
<div className="body-md mt-3" style={{ fontStyle: "italic", color: "var(--color-muted)" }}>
&ldquo;{scenario.query}&rdquo;
</div>
</div>
<div className="flex items-center gap-4">
<label className="caption whitespace-nowrap flex items-center gap-2">
Hops:
<strong style={{ color: "var(--color-tiger-orange)", fontFamily: "var(--font-mono)", minWidth: "1ch" }}>{hops}</strong>
<input
type="range" min={1} max={4} step={1} value={hops}
onChange={e => { setHops(+e.target.value); setSelectedNode(null); }}
className="w-24 accent-[#FF6B00]"
/>
</label>
<span className="badge-outline" style={{ fontSize: "0.6875rem" }}>
{visibleNodes.length}/{nodes.length} nodes Β· {visibleEdges.length}/{edges.length} edges
</span>
</div>
</div>
</div>
<div className="grid grid-cols-1 xl:grid-cols-4 gap-6">
{/* Graph SVG β€” 3 cols */}
<div className="xl:col-span-3">
<div className="card animate-scale-in" style={{ padding: "16px" }}>
<svg viewBox="0 0 900 520" style={{ width: "100%", minHeight: "480px" }}>
<defs>
<filter id="glow">
<feGaussianBlur stdDeviation="3" result="coloredBlur"/>
<feMerge>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<marker id="arrow" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
<polygon points="0 0, 8 3, 0 6" fill="#c8c3bb" />
</marker>
<marker id="arrow-hot" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
<polygon points="0 0, 8 3, 0 6" fill="#FF6B00" />
</marker>
</defs>
{/* Dimmed nodes that are hidden by hop filter */}
{nodes.filter(n => !reachabilityMap.has(n.id)).map(node => (
<circle key={`dim-${node.id}`} cx={node.x} cy={node.y} r={18}
fill={TYPE_COLORS[node.type] || "#AED6F1"} opacity={0.08} />
))}
{/* Edges */}
{visibleEdges.map((edge, i) => {
const s = nodeMap[edge.source];
const t = nodeMap[edge.target];
if (!s || !t) return null;
const isConnected = selectedNode && (edge.source === selectedNode || edge.target === selectedNode);
const dimmed = selectedNode && !isConnected;
const mx = (s.x + t.x) / 2;
const my = (s.y + t.y) / 2 - 10;
return (
<g key={`edge-${i}`} opacity={dimmed ? 0.12 : 1}>
<line
x1={s.x} y1={s.y} x2={t.x} y2={t.y}
stroke={isConnected ? "#FF6B00" : "#d1cdc5"}
strokeWidth={isConnected ? 2.5 : 1.5}
markerEnd={isConnected ? "url(#arrow-hot)" : "url(#arrow)"}
/>
<text x={mx} y={my} textAnchor="middle" fontSize="8.5"
fill={isConnected ? "#FF6B00" : "#9e9990"}
fontFamily="var(--font-mono)" fontWeight={isConnected ? 600 : 400}>
{edge.label}
</text>
</g>
);
})}
{/* Nodes */}
{visibleNodes.map(node => {
const color = TYPE_COLORS[node.type] || "#AED6F1";
const isSelected = selectedNode === node.id;
const isConnected = connectedEdges.some(e => e.source === node.id || e.target === node.id);
const dimmed = selectedNode && !isSelected && !isConnected;
const depth = reachabilityMap.get(node.id) ?? 0;
const r = node.type === "QUERY" ? 26 : isSelected ? 24 : 20;
return (
<g
key={node.id}
style={{ cursor: "pointer" }}
onClick={() => setSelectedNode(isSelected ? null : node.id)}
opacity={dimmed ? 0.18 : 1}
>
{/* Glow rings for selected */}
{isSelected && (
<>
<circle cx={node.x} cy={node.y} r={r + 14} fill={color} opacity={0.10} />
<circle cx={node.x} cy={node.y} r={r + 7} fill={color} opacity={0.15} filter="url(#glow)" />
</>
)}
<circle
cx={node.x} cy={node.y} r={r}
fill={color}
stroke={isSelected ? "white" : "rgba(255,255,255,0.7)"}
strokeWidth={isSelected ? 3 : 2}
/>
{/* Hop depth badge (small dot in top-right of node) */}
{depth > 0 && (
<text x={node.x + r - 2} y={node.y - r + 6}
textAnchor="middle" fontSize="8" fill="white"
fontFamily="var(--font-mono)" fontWeight="700">
{depth}
</text>
)}
{node.type === "QUERY" && (
<text x={node.x} y={node.y + 5} textAnchor="middle"
fontSize="15" fill="white" fontWeight="bold">?</text>
)}
<text
x={node.x} y={node.y + r + 15}
textAnchor="middle"
fontSize={isSelected ? "11.5" : "10"}
fontWeight={isSelected ? "600" : "400"}
fill="#141413"
fontFamily="var(--font-sans)"
>
{node.label}
</text>
</g>
);
})}
</svg>
</div>
</div>
{/* Sidebar */}
<div className="flex flex-col gap-4">
{/* Node Details */}
<div className="card-dark animate-fade-in-up delay-100">
<div className="caption-uppercase mb-3" style={{ color: "#a09d96" }}>
{selectedInfo ? "Node Details" : "Select a Node"}
</div>
{selectedInfo ? (
<div>
<div className="title-lg" style={{ color: "#faf9f5" }}>{selectedInfo.label}</div>
<div className="flex items-center gap-2 mt-3 flex-wrap">
<span className="badge" style={{
background: TYPE_COLORS[selectedInfo.type] || "#AED6F1",
color: "white", fontSize: "0.6875rem",
}}>
{selectedInfo.type}
</span>
{selectedDepth !== undefined && (
<span className="badge-outline" style={{ fontSize: "0.6875rem" }}>
Hop {selectedDepth} from query
</span>
)}
</div>
{selectedInfo.description && (
<p className="body-sm mt-3" style={{ color: "#a09d96", lineHeight: 1.6 }}>
{selectedInfo.description}
</p>
)}
<div className="mt-4 pt-4" style={{ borderTop: "1px solid rgba(255,255,255,0.08)" }}>
<div className="caption mb-2" style={{ color: "#a09d96" }}>
{connectedEdges.length} visible connection{connectedEdges.length !== 1 ? "s" : ""}
</div>
{connectedEdges.map((e, i) => {
const otherId = e.source === selectedInfo.id ? e.target : e.source;
const otherNode = nodeMap[otherId];
const dir = e.source === selectedInfo.id ? "β†’" : "←";
return (
<div key={i} className="flex items-center gap-2 mb-2"
style={{
padding: "6px 10px", borderRadius: "8px",
background: "rgba(255,255,255,0.04)", cursor: "pointer",
}}
onClick={() => setSelectedNode(otherId)}>
<div className="w-2.5 h-2.5 rounded-full flex-shrink-0" style={{
background: TYPE_COLORS[otherNode?.type ?? ""] || "#AED6F1",
}} />
<span style={{ color: "#FF6B00", fontFamily: "var(--font-mono)", fontSize: "0.7rem" }}>
{dir} {e.label}
</span>
<span style={{ color: "#faf9f5", fontSize: "0.8rem" }}>
{otherNode?.label}
</span>
</div>
);
})}
</div>
</div>
) : (
<p className="body-sm" style={{ color: "#a09d96" }}>
Click any visible node to inspect its entity type, hop distance from the query, and connections.
Use the hops slider to see the graph expand step by step.
</p>
)}
</div>
{/* Graph Stats */}
<div className="card-cream animate-fade-in-up delay-200">
<div className="caption-uppercase mb-3">Graph Statistics</div>
{[
{ label: "Visible Nodes", value: `${visibleNodes.length} / ${nodes.length}`, color: "#FF6B00" },
{ label: "Visible Edges", value: `${visibleEdges.length} / ${edges.length}`, color: "#0072CE" },
{ label: "Max Hops", value: hops, color: "#5db8a6" },
{ label: "Avg Degree", value: visibleNodes.length > 0 ? (visibleEdges.length * 2 / visibleNodes.length).toFixed(1) : "0", color: "#cc785c" },
{ label: "Entity Types", value: new Set(visibleNodes.map(n => n.type)).size, color: "#002B49" },
].map((s, i) => (
<div key={i} className="flex justify-between items-center py-2.5"
style={{ borderBottom: i < 4 ? "1px solid var(--color-hairline-soft)" : "none" }}>
<span className="body-sm">{s.label}</span>
<span className="title-sm" style={{ fontFamily: "var(--font-mono)", color: s.color }}>{s.value}</span>
</div>
))}
</div>
{/* Legend */}
<div className="card animate-fade-in-up delay-300" style={{ padding: "20px" }}>
<div className="caption-uppercase mb-3">Entity Types</div>
<div className="grid grid-cols-2 gap-2">
{Object.entries(TYPE_COLORS).map(([type, color]) => (
<div key={type} className="flex items-center gap-2">
<div className="w-3 h-3 rounded-full" style={{ background: color }} />
<span className="body-sm">{type}</span>
</div>
))}
</div>
<p className="body-sm mt-3" style={{ color: "var(--color-muted)", fontSize: "0.7rem" }}>
Small number on each node = hop distance from query node.
</p>
</div>
</div>
</div>
{/* Reasoning Steps */}
<div className="card mt-8 animate-fade-in-up delay-400">
<div className="title-lg mb-6">🧠 Graph Reasoning Path</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{reasoning.map((step, i) => {
const active = i < hops;
return (
<div key={i} className={active ? "card" : "card"} style={{
padding: "20px", position: "relative",
opacity: active ? 1 : 0.38,
borderLeft: active ? `3px solid var(--color-tiger-orange)` : undefined,
transition: "opacity 0.25s ease",
}}>
<div style={{
position: "absolute", top: "12px", right: "12px",
fontFamily: "var(--font-mono)", fontSize: "0.6875rem",
color: active ? "var(--color-tiger-orange)" : "var(--color-muted)", fontWeight: 600,
}}>
Step {i + 1}
</div>
<p className="body-sm" style={{ color: "var(--color-body)", lineHeight: 1.6 }}>
{step}
</p>
</div>
);
})}
</div>
<p className="body-sm mt-4" style={{ color: "var(--color-muted)", fontStyle: "italic" }}>
Steps highlight based on the current hop depth. Drag the slider above to walk through the reasoning.
</p>
</div>
{/* Live Query Section */}
<div className="card mt-8 animate-fade-in-up">
<div className="title-lg mb-2">πŸ”΄ Live Entity Query</div>
<p className="body-sm mb-4" style={{ color: "var(--color-muted)" }}>
Ask a <strong>science</strong> question β€” GraphRAG retrieves entities from TigerGraph and renders them
as a live graph. Corpus covers physics, biology, chemistry, and astronomy.
</p>
{/* Example chips */}
<div className="flex flex-wrap gap-2 mb-5">
{[
"How do black holes form?",
"What is CRISPR and how does it edit DNA?",
"How does photosynthesis produce oxygen?",
"What causes quantum entanglement?",
"How does the immune system fight viruses?",
].map(q => (
<button
key={q}
onClick={() => setLiveQuery(q)}
className="badge-outline"
style={{ cursor: "pointer", fontSize: "0.75rem", border: "none" }}
>
{q}
</button>
))}
</div>
<div className="flex gap-3 mb-5">
<input
className="input flex-1"
placeholder="e.g. How does DNA encode proteins?"
value={liveQuery}
onChange={e => setLiveQuery(e.target.value)}
onKeyDown={e => { if (e.key === "Enter") runLiveQuery(); }}
/>
<button className="btn btn-primary" onClick={runLiveQuery}
disabled={liveLoading || !liveQuery.trim()}>
{liveLoading ? (
<span className="flex items-center gap-2">
<span className="animate-spin inline-block w-4 h-4 border-2 border-white border-t-transparent rounded-full" />
Querying…
</span>
) : "Run Live"}
</button>
</div>
{liveError && (
<div className="card-cream mb-4" style={{ padding: "12px 16px", borderLeft: "3px solid #e17055" }}>
<span className="body-sm" style={{ color: "#d63031" }}>{liveError}</span>
</div>
)}
{liveAnswer && !liveGraph && (
<div className="card-cream mb-4" style={{ padding: "16px", borderLeft: "3px solid #0072CE" }}>
<div className="caption-uppercase mb-2" style={{ color: "#0072CE" }}>GraphRAG Answer</div>
<p className="body-sm" style={{ lineHeight: 1.65, color: "var(--color-body)" }}>{liveAnswer}</p>
</div>
)}
{liveGraph && (
<div>
<div className="caption mb-3" style={{ color: "var(--color-muted)" }}>
{liveGraph.nodes.length - 1} entities retrieved from TigerGraph β€” star topology (query β†’ entities, hop 1)
</div>
<div className="card" style={{ padding: "16px" }}>
<svg viewBox="0 0 900 500" style={{ width: "100%", minHeight: "400px" }}>
<defs>
<marker id="arrow-live" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto">
<polygon points="0 0, 8 3, 0 6" fill="#FF6B00" />
</marker>
</defs>
{liveGraph.edges.map((e, i) => {
const s = liveGraph.nodes.find(n => n.id === e.source);
const t = liveGraph.nodes.find(n => n.id === e.target);
if (!s || !t) return null;
return (
<line key={i} x1={s.x} y1={s.y} x2={t.x} y2={t.y}
stroke="#FF6B00" strokeWidth="1.5" strokeOpacity={0.5}
markerEnd="url(#arrow-live)" />
);
})}
{liveGraph.nodes.map((node, i) => {
const isQuery = node.id === "q";
const color = isQuery ? "#FF6B00" : "#AED6F1";
const r = isQuery ? 26 : 20;
return (
<g key={i}>
<circle cx={node.x} cy={node.y} r={r} fill={color}
stroke="white" strokeWidth="2.5" />
{isQuery && (
<text x={node.x} y={node.y + 5} textAnchor="middle"
fontSize="14" fill="white" fontWeight="bold">?</text>
)}
<foreignObject x={node.x - 60} y={node.y + r + 6} width="120" height="40">
<div style={{
fontSize: "9.5px", textAlign: "center", color: "#141413",
lineHeight: 1.35, wordBreak: "break-word",
fontFamily: "var(--font-sans)",
}}>
{node.label}
</div>
</foreignObject>
</g>
);
})}
</svg>
</div>
</div>
)}
</div>
</div>
);
}