civicsetu / frontend /src /components /graph /GraphExplorer.tsx
adeshboudh16
feat: decouple frontend + multi-turn conversation + graph explorer + CAG layer
d91cbff
'use client';
import { useEffect, useRef, useState } from 'react';
import { ForceGraph } from './ForceGraph';
import { GraphFilterSidebar } from './GraphFilterSidebar';
import { SectionDrawer } from './SectionDrawer';
import type { UseGraphExplorerReturn } from '@/hooks/useGraphExplorer';
interface Props extends UseGraphExplorerReturn {
onChatAboutSection: (
sectionId: string,
title: string,
docName: string,
jurisdiction: string,
) => void;
}
export function GraphExplorer({
topology,
isLoading,
error,
selectedNode,
hoveredNode,
sectionContent,
isSectionLoading,
filters,
setSelectedNode,
setHoveredNode,
setFilters,
navigateToNode,
onChatAboutSection,
}: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const [dims, setDims] = useState({ width: 0, height: 0 });
useEffect(() => {
if (!containerRef.current) {
return;
}
const ro = new ResizeObserver(entries => {
for (const entry of entries) {
const { width, height } = entry.contentRect;
setDims({ width: Math.floor(width), height: Math.floor(height) });
}
});
ro.observe(containerRef.current);
return () => ro.disconnect();
}, []);
const nodes = topology?.nodes ?? [];
const edges = topology?.edges ?? [];
return (
<div className="flex h-full min-h-0 flex-col bg-[#0d0d0d]">
<div className="flex h-12 shrink-0 items-center justify-between border-b border-white/[0.06] px-4">
<div>
<p className="font-mono text-[10px] uppercase tracking-[0.24em] text-white/30">Graph Explorer</p>
<p className="mt-0.5 text-[12px] text-white/40">
{nodes.length} sections / {edges.length} relationships
</p>
</div>
<div className="flex items-center gap-2 font-mono text-[10px] uppercase tracking-[0.2em] text-white/30">
<span className="h-1.5 w-1.5 rounded-full bg-[#4f98a3]" />
Live topology
</div>
</div>
<div ref={containerRef} className="relative min-h-0 flex-1 overflow-hidden bg-[#0d0d0d]">
<div className="pointer-events-none absolute inset-0 opacity-[0.14] [background-image:radial-gradient(circle_at_center,rgba(229,226,225,0.72)_1px,transparent_1.5px)] [background-position:0_0] [background-size:24px_24px]" />
{isLoading ? (
<div className="absolute inset-0 z-10 flex items-center justify-center">
<div className="flex items-center gap-2 font-mono text-[10px] uppercase tracking-[0.24em] text-white/30">
{[0, 1, 2].map(i => (
<span
key={i}
className="h-1.5 w-1.5 rounded-full bg-white/25 animate-pulse"
style={{ animationDelay: `${i * 140}ms` }}
/>
))}
Loading graph
</div>
</div>
) : null}
{error ? (
<div className="absolute inset-0 z-10 flex items-center justify-center">
<div className="max-w-xs border border-red-300/15 bg-red-950/10 p-4 text-center">
<p className="text-sm text-red-200/80">Graph unavailable</p>
<p className="mt-1 text-xs leading-5 text-white/30">{error}</p>
</div>
</div>
) : null}
{!isLoading && !error && nodes.length === 0 ? (
<div className="absolute inset-0 z-10 flex items-center justify-center">
<div className="border border-white/[0.06] bg-[#141414] p-5 text-center">
<p className="text-sm text-white/60">No section relationships found</p>
<p className="mt-1 text-xs text-white/30">Run ingestion to populate the graph.</p>
</div>
</div>
) : null}
{dims.width > 0 && dims.height > 0 && nodes.length > 0 ? (
<ForceGraph
nodes={nodes}
edges={edges}
filters={filters}
selectedNode={selectedNode}
hoveredNode={hoveredNode}
onNodeClick={node => setSelectedNode(node)}
onNodeHover={setHoveredNode}
width={dims.width}
height={dims.height}
/>
) : null}
<GraphFilterSidebar filters={filters} onFiltersChange={setFilters} topology={topology} />
<SectionDrawer
content={sectionContent}
isLoading={isSectionLoading}
onClose={() => setSelectedNode(null)}
onNodeNavigate={navigateToNode}
onChatAboutSection={onChatAboutSection}
/>
</div>
</div>
);
}