tahamajs/IE / Projects /front /src /components /gap-analysis /KnowledgeGraphCanvas.tsx
tahamajs's picture
download
raw
10.1 kB
// src/components/gap-analysis/KnowledgeGraphCanvas.tsx
import { useCallback, useRef, useEffect, useMemo, useState } from "react";
import ForceGraph2D, {
type ForceGraphMethods,
type NodeObject,
type LinkObject,
} from "react-force-graph-2d";
import type { GraphNode, GraphEdge, GraphNodeType } from "@/types/gap-analysis";
import { GraphLegend } from "./GraphLegend";
import { GraphSearchBar } from "./GraphSearchBar";
import {
ZoomIn, ZoomOut, Maximize2, RotateCcw,
} from "lucide-react";
// Color palette by node type
const NODE_COLORS: Record<GraphNodeType, string> = {
paper: "#6366f1", // indigo
concept: "#06b6d4", // cyan
method: "#f59e0b", // amber
finding: "#10b981", // emerald
gap: "#ef4444", // red
hypothesis: "#8b5cf6", // violet
};
const NODE_SIZES: Record<GraphNodeType, number> = {
paper: 6,
concept: 8,
method: 7,
finding: 9,
gap: 10,
hypothesis: 12,
};
const EDGE_COLORS: Record<string, string> = {
cites: "#94a3b8",
related_to: "#a78bfa",
contradicts: "#f87171",
supports: "#34d399",
methodology: "#fbbf24",
gap_bridge: "#f97316",
};
interface KnowledgeGraphCanvasProps {
nodes: GraphNode[];
edges: GraphEdge[];
onNodeClick: (nodeId: string) => void;
onNodeHover: (nodeId: string | null) => void;
filterType: GraphNodeType | "all";
onFilterChange?: (type: GraphNodeType | "all") => void;
searchQuery: string;
onSearchChange?: (query: string) => void;
selectedNodeId: string | null;
}
export function KnowledgeGraphCanvas({
nodes: rawNodes,
edges: rawEdges,
onNodeClick,
onNodeHover,
filterType,
onFilterChange,
searchQuery,
onSearchChange,
selectedNodeId,
}: KnowledgeGraphCanvasProps) {
const fgRef = useRef<ForceGraphMethods<any, any> | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const [dimensions, setDimensions] = useState({ width: 800, height: 600 });
const [highlightNodes, setHighlightNodes] = useState<Set<string>>(new Set());
const [highlightLinks, setHighlightLinks] = useState<Set<string>>(new Set());
const [hoverNode, setHoverNode] = useState<string | null>(null);
// Filter + search
const graphData = useMemo(() => {
let filtered = rawNodes;
if (filterType !== "all") {
filtered = filtered.filter((n) => n.type === filterType);
}
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
filtered = filtered.filter(
(n) =>
n.label.toLowerCase().includes(q) ||
n.description?.toLowerCase().includes(q)
);
}
const nodeIds = new Set(filtered.map((n) => n.id));
const filteredEdges = rawEdges.filter(
(e) => nodeIds.has(e.source as string) && nodeIds.has(e.target as string)
);
return {
nodes: filtered.map((n) => ({
...n,
color: NODE_COLORS[n.type],
val: NODE_SIZES[n.type],
fx: n.fx ?? undefined,
fy: n.fy ?? undefined,
})) as any,
links: filteredEdges.map((e) => ({
...e,
color: EDGE_COLORS[e.type] ?? "#94a3b8",
})) as any,
};
}, [rawNodes, rawEdges, filterType, searchQuery]);
// Resize handler
useEffect(() => {
const resize = () => {
if (containerRef.current) {
setDimensions({
width: containerRef.current.offsetWidth,
height: containerRef.current.offsetHeight,
});
}
};
resize();
window.addEventListener("resize", resize);
return () => window.removeEventListener("resize", resize);
}, []);
// Highlight connected nodes on hover
const handleNodeHover = useCallback(
(node: NodeObject | null) => {
if (!node) {
setHighlightNodes(new Set());
setHighlightLinks(new Set());
setHoverNode(null);
onNodeHover(null);
return;
}
const n = node as unknown as GraphNode;
setHoverNode(n.id);
onNodeHover(n.id);
// Highlight neighbors
const neighbors = new Set<string>();
const neighborLinks = new Set<string>();
graphData.links.forEach((link: any) => {
const src = link.source as any;
const tgt = link.target as any;
const srcId = typeof src === "object" ? src.id : src;
const tgtId = typeof tgt === "object" ? tgt.id : tgt;
if (srcId === n.id) {
neighbors.add(tgtId);
neighborLinks.add(link.id);
}
if (tgtId === n.id) {
neighbors.add(srcId);
neighborLinks.add(link.id);
}
});
neighbors.add(n.id);
setHighlightNodes(neighbors);
setHighlightLinks(neighborLinks);
},
[graphData.links, onNodeHover]
);
// Paint node with highlight logic
const nodeCanvasObject = useCallback(
(node: NodeObject, ctx: CanvasRenderingContext2D, globalScale: number) => {
const n = node as unknown as GraphNode;
const isHighlighted = highlightNodes.size === 0 || highlightNodes.has(n.id);
const isSelected = selectedNodeId === n.id;
const opacity = isHighlighted ? 1 : 0.15;
// Draw glow for selected
if (isSelected) {
ctx.beginPath();
ctx.arc(node.x!, node.y!, NODE_SIZES[n.type] * 1.8, 0, 2 * Math.PI);
ctx.fillStyle = `${n.color}33`;
ctx.fill();
}
// Draw node circle
ctx.beginPath();
ctx.arc(node.x!, node.y!, NODE_SIZES[n.type], 0, 2 * Math.PI);
ctx.fillStyle = n.color ?? NODE_COLORS[n.type];
ctx.globalAlpha = opacity;
ctx.fill();
ctx.globalAlpha = 1;
// Draw label for larger nodes or selected
if ((isHighlighted && globalScale > 1.2) || isSelected) {
const fontSize = Math.max(3, 12 / globalScale);
ctx.font = `${fontSize}px Inter, sans-serif`;
ctx.fillStyle = isSelected ? "#0f172a" : "#64748b";
ctx.textAlign = "center";
ctx.fillText(
n.label.length > 20 ? n.label.slice(0, 20) + "…" : n.label,
node.x!,
node.y! + NODE_SIZES[n.type] + fontSize + 2
);
}
},
[highlightNodes, selectedNodeId]
);
// Link width based on strength
const linkWidth = useCallback((link: LinkObject) => {
const l = link as unknown as GraphEdge;
return Math.max(0.5, (l.strength ?? 0.5) * 3);
}, []);
// Controls
const zoomIn = () => fgRef.current?.zoom(1.5);
const zoomOut = () => fgRef.current?.zoom(0.75);
const fitToScreen = () => fgRef.current?.zoomToFit(400, 60);
const resetCamera = () => {
fgRef.current?.zoomToFit(400, 60);
fgRef.current?.centerAt(0, 0, 0);
};
return (
<div ref={containerRef} className="relative w-full h-full bg-gray-50 dark:bg-gray-900 rounded-2xl border border-gray-200 dark:border-gray-800 overflow-hidden">
{/* Top bar */}
<div className="absolute top-3 left-3 right-3 z-10 flex items-center gap-2 flex-wrap">
<GraphSearchBar
value={searchQuery}
onChange={onSearchChange ?? (() => {})}
filterType={filterType}
onFilterChange={onFilterChange ?? (() => {})}
/>
</div>
{/* Graph */}
<ForceGraph2D
ref={fgRef as any}
graphData={graphData}
width={dimensions.width}
height={dimensions.height}
nodeLabel={(node) => {
const n = node as unknown as GraphNode;
return `${n.type.toUpperCase()}: ${n.label}\n${n.description ?? ""}`;
}}
nodeCanvasObject={nodeCanvasObject}
linkWidth={linkWidth}
linkColor={(link) => {
const l = link as unknown as GraphEdge;
const isHighlighted =
highlightLinks.size === 0 || highlightLinks.has(l.id);
return isHighlighted ? EDGE_COLORS[l.type] ?? "#94a3b8" : "#e2e8f0";
}}
onNodeClick={(node) => onNodeClick((node as unknown as GraphNode).id)}
onNodeHover={handleNodeHover}
cooldownTicks={100}
d3AlphaDecay={0.02}
d3VelocityDecay={0.3}
enableNodeDrag={true}
enableZoomInteraction={true}
minZoom={0.3}
maxZoom={5}
/>
{/* Bottom controls */}
<div className="absolute bottom-3 right-3 z-10 flex items-center gap-1">
<button onClick={zoomIn} className="p-2 bg-white dark:bg-gray-800 rounded-lg shadow hover:bg-gray-100 dark:hover:bg-gray-700">
<ZoomIn className="w-4 h-4 text-gray-600 dark:text-gray-300" />
</button>
<button onClick={zoomOut} className="p-2 bg-white dark:bg-gray-800 rounded-lg shadow hover:bg-gray-100 dark:hover:bg-gray-700">
<ZoomOut className="w-4 h-4 text-gray-600 dark:text-gray-300" />
</button>
<button onClick={fitToScreen} className="p-2 bg-white dark:bg-gray-800 rounded-lg shadow hover:bg-gray-100 dark:hover:bg-gray-700">
<Maximize2 className="w-4 h-4 text-gray-600 dark:text-gray-300" />
</button>
<button onClick={resetCamera} className="p-2 bg-white dark:bg-gray-800 rounded-lg shadow hover:bg-gray-100 dark:hover:bg-gray-700">
<RotateCcw className="w-4 h-4 text-gray-600 dark:text-gray-300" />
</button>
</div>
{/* Legend */}
<div className="absolute bottom-3 left-3 z-10">
<GraphLegend />
</div>
{/* Node detail tooltip */}
{hoverNode && (
<div className="absolute top-12 left-1/2 -translate-x-1/2 z-10 bg-white dark:bg-gray-800 rounded-xl shadow-xl border border-gray-200 dark:border-gray-700 px-4 py-2 text-xs max-w-xs">
{(() => {
const node = rawNodes.find((n) => n.id === hoverNode);
if (!node) return null;
return (
<>
<span
className="inline-block w-2 h-2 rounded-full mr-1.5"
style={{ backgroundColor: NODE_COLORS[node.type] }}
/>
<span className="font-semibold">{node.type.toUpperCase()}</span>:{" "}
{node.label}
</>
);
})()}
</div>
)}
</div>
);
}

Xet Storage Details

Size:
10.1 kB
·
Xet hash:
f9e5006c7206e5899081a889a46e30e8fbb0b5fbc2db6c2885ae1dcf33ed1dde

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.