- Create KnowledgeGraphViewer component with @xyflow/react - Implement three layout types: force-directed, hierarchical (ELK), circular - Add node sizing based on connection count (40px-120px range) - Apply PDA-friendly status colors (green=published, blue=draft, gray=archived) - Highlight orphan nodes with distinct color - Add interactive features: zoom, pan, click-to-navigate - Implement filters: status, tags, show/hide orphans - Add statistics display and legend panel - Create comprehensive test suite (16 tests, all passing) - Add fetchKnowledgeGraph API function - Create /knowledge/graph page - Performance tested with 500+ nodes - All quality gates passed (tests, typecheck, lint) Refs #72 Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
571 lines
16 KiB
TypeScript
571 lines
16 KiB
TypeScript
"use client";
|
|
|
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import { useRouter } from "next/navigation";
|
|
import type { Node, Edge } from "@xyflow/react";
|
|
import {
|
|
ReactFlow,
|
|
Background,
|
|
Controls,
|
|
MiniMap,
|
|
Panel,
|
|
useNodesState,
|
|
useEdgesState,
|
|
MarkerType,
|
|
BackgroundVariant,
|
|
} from "@xyflow/react";
|
|
import "@xyflow/react/dist/style.css";
|
|
import { fetchKnowledgeGraph } from "@/lib/api/knowledge";
|
|
import ELK from "elkjs/lib/elk.bundled.js";
|
|
|
|
// PDA-friendly status colors from CLAUDE.md
|
|
const STATUS_COLORS = {
|
|
PUBLISHED: "#10B981", // green-500 - Active
|
|
DRAFT: "#3B82F6", // blue-500 - Scheduled/Upcoming
|
|
ARCHIVED: "#9CA3AF", // gray-400 - Dormant
|
|
} as const;
|
|
|
|
const ORPHAN_COLOR = "#D1D5DB"; // gray-300 - Orphaned nodes
|
|
|
|
type LayoutType = "force" | "hierarchical" | "circular";
|
|
|
|
interface GraphNode {
|
|
id: string;
|
|
slug: string;
|
|
title: string;
|
|
summary: string | null;
|
|
status?: string;
|
|
tags: {
|
|
id: string;
|
|
name: string;
|
|
slug: string;
|
|
color: string | null;
|
|
}[];
|
|
depth: number;
|
|
isOrphan?: boolean;
|
|
}
|
|
|
|
interface GraphEdge {
|
|
id: string;
|
|
sourceId: string;
|
|
targetId: string;
|
|
linkText: string;
|
|
}
|
|
|
|
interface GraphData {
|
|
nodes: GraphNode[];
|
|
edges: GraphEdge[];
|
|
stats: {
|
|
totalNodes: number;
|
|
totalEdges: number;
|
|
orphanCount: number;
|
|
};
|
|
}
|
|
|
|
interface KnowledgeGraphViewerProps {
|
|
initialFilters?: {
|
|
tags?: string[];
|
|
status?: string;
|
|
limit?: number;
|
|
};
|
|
}
|
|
|
|
const elk = new ELK();
|
|
|
|
/**
|
|
* Calculate node size based on number of connections
|
|
*/
|
|
function calculateNodeSize(connectionCount: number): number {
|
|
const minSize = 40;
|
|
const maxSize = 120;
|
|
const size = minSize + Math.min(connectionCount * 8, maxSize - minSize);
|
|
return size;
|
|
}
|
|
|
|
/**
|
|
* Get node color based on status (PDA-friendly)
|
|
*/
|
|
function getNodeColor(node: GraphNode): string {
|
|
if (node.isOrphan) {
|
|
return ORPHAN_COLOR;
|
|
}
|
|
const status = node.status as keyof typeof STATUS_COLORS;
|
|
return status in STATUS_COLORS ? STATUS_COLORS[status] : STATUS_COLORS.PUBLISHED;
|
|
}
|
|
|
|
/**
|
|
* Calculate connection count for each node
|
|
*/
|
|
function calculateConnectionCounts(nodes: GraphNode[], edges: GraphEdge[]): Map<string, number> {
|
|
const counts = new Map<string, number>();
|
|
|
|
// Initialize all nodes with 0
|
|
nodes.forEach((node) => counts.set(node.id, 0));
|
|
|
|
// Count connections
|
|
edges.forEach((edge) => {
|
|
counts.set(edge.sourceId, (counts.get(edge.sourceId) ?? 0) + 1);
|
|
counts.set(edge.targetId, (counts.get(edge.targetId) ?? 0) + 1);
|
|
});
|
|
|
|
return counts;
|
|
}
|
|
|
|
/**
|
|
* Convert graph data to ReactFlow nodes
|
|
*/
|
|
function convertToReactFlowNodes(
|
|
nodes: GraphNode[],
|
|
edges: GraphEdge[],
|
|
layout: LayoutType
|
|
): Node[] {
|
|
const connectionCounts = calculateConnectionCounts(nodes, edges);
|
|
|
|
return nodes.map((node, index) => {
|
|
const connectionCount = connectionCounts.get(node.id) ?? 0;
|
|
const size = calculateNodeSize(connectionCount);
|
|
const color = getNodeColor(node);
|
|
|
|
return {
|
|
id: node.id,
|
|
type: "default",
|
|
position: getInitialPosition(index, nodes.length, layout),
|
|
data: {
|
|
label: node.title,
|
|
slug: node.slug,
|
|
summary: node.summary,
|
|
status: node.status,
|
|
tags: node.tags,
|
|
connectionCount,
|
|
isOrphan: node.isOrphan,
|
|
},
|
|
style: {
|
|
width: size,
|
|
height: size,
|
|
backgroundColor: color,
|
|
color: "#FFFFFF",
|
|
border: "2px solid #FFFFFF",
|
|
borderRadius: "50%",
|
|
display: "flex",
|
|
alignItems: "center",
|
|
justifyContent: "center",
|
|
padding: "8px",
|
|
fontSize: Math.max(10, size / 8),
|
|
fontWeight: 600,
|
|
textAlign: "center",
|
|
cursor: "pointer",
|
|
boxShadow: "0 4px 6px rgba(0, 0, 0, 0.1)",
|
|
},
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Convert graph data to ReactFlow edges
|
|
*/
|
|
function convertToReactFlowEdges(edges: GraphEdge[]): Edge[] {
|
|
return edges.map((edge) => ({
|
|
id: edge.id,
|
|
source: edge.sourceId,
|
|
target: edge.targetId,
|
|
type: "smoothstep",
|
|
animated: false,
|
|
markerEnd: {
|
|
type: MarkerType.ArrowClosed,
|
|
width: 20,
|
|
height: 20,
|
|
},
|
|
style: {
|
|
strokeWidth: 2,
|
|
stroke: "#94A3B8", // slate-400
|
|
},
|
|
label: edge.linkText,
|
|
labelStyle: {
|
|
fontSize: 10,
|
|
fill: "#64748B", // slate-500
|
|
},
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* Get initial position based on layout type
|
|
*/
|
|
function getInitialPosition(
|
|
index: number,
|
|
totalNodes: number,
|
|
layout: LayoutType
|
|
): { x: number; y: number } {
|
|
switch (layout) {
|
|
case "circular": {
|
|
const radius = Math.max(300, totalNodes * 20);
|
|
const angle = (index / totalNodes) * 2 * Math.PI;
|
|
return {
|
|
x: 400 + radius * Math.cos(angle),
|
|
y: 400 + radius * Math.sin(angle),
|
|
};
|
|
}
|
|
case "hierarchical": {
|
|
const cols = Math.ceil(Math.sqrt(totalNodes));
|
|
return {
|
|
x: (index % cols) * 250,
|
|
y: Math.floor(index / cols) * 200,
|
|
};
|
|
}
|
|
case "force":
|
|
default:
|
|
// Random initial positions for force layout
|
|
return {
|
|
x: Math.random() * 800,
|
|
y: Math.random() * 600,
|
|
};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Apply ELK hierarchical layout
|
|
*/
|
|
async function applyElkLayout(nodes: Node[], edges: Edge[]): Promise<Node[]> {
|
|
const graph = {
|
|
id: "root",
|
|
layoutOptions: {
|
|
"elk.algorithm": "layered",
|
|
"elk.direction": "DOWN",
|
|
"elk.spacing.nodeNode": "80",
|
|
"elk.layered.spacing.nodeNodeBetweenLayers": "100",
|
|
},
|
|
children: nodes.map((node) => ({
|
|
id: node.id,
|
|
width: typeof node.style?.width === "number" ? node.style.width : 100,
|
|
height: typeof node.style?.height === "number" ? node.style.height : 100,
|
|
})),
|
|
edges: edges.map((edge) => ({
|
|
id: edge.id,
|
|
sources: [edge.source],
|
|
targets: [edge.target],
|
|
})),
|
|
};
|
|
|
|
const layout = await elk.layout(graph);
|
|
|
|
return nodes.map((node) => {
|
|
const elkNode = layout.children?.find((n) => n.id === node.id);
|
|
if (elkNode?.x !== undefined && elkNode.y !== undefined) {
|
|
return {
|
|
...node,
|
|
position: { x: elkNode.x, y: elkNode.y },
|
|
};
|
|
}
|
|
return node;
|
|
});
|
|
}
|
|
|
|
export function KnowledgeGraphViewer({
|
|
initialFilters,
|
|
}: KnowledgeGraphViewerProps): React.JSX.Element {
|
|
const router = useRouter();
|
|
const [graphData, setGraphData] = useState<GraphData | null>(null);
|
|
const [isLoading, setIsLoading] = useState(true);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [layout, setLayout] = useState<LayoutType>("force");
|
|
const [showOrphans, setShowOrphans] = useState(true);
|
|
const [statusFilter, setStatusFilter] = useState<string>(initialFilters?.status ?? "");
|
|
const [tagFilter, setTagFilter] = useState<string>("");
|
|
|
|
// Load graph data
|
|
const loadGraph = useCallback(async (): Promise<void> => {
|
|
try {
|
|
setIsLoading(true);
|
|
setError(null);
|
|
const data = await fetchKnowledgeGraph(initialFilters);
|
|
setGraphData(data);
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : "Failed to load graph");
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}, [initialFilters]);
|
|
|
|
useEffect(() => {
|
|
void loadGraph();
|
|
}, [loadGraph]);
|
|
|
|
// Filter nodes based on criteria
|
|
const filteredNodes = useMemo(() => {
|
|
if (!graphData) return [];
|
|
|
|
let filtered = graphData.nodes;
|
|
|
|
// Filter by orphan status
|
|
if (!showOrphans) {
|
|
filtered = filtered.filter((node) => !node.isOrphan);
|
|
}
|
|
|
|
// Filter by status
|
|
if (statusFilter) {
|
|
filtered = filtered.filter((node) => node.status === statusFilter);
|
|
}
|
|
|
|
// Filter by tag
|
|
if (tagFilter) {
|
|
const lowerFilter = tagFilter.toLowerCase();
|
|
filtered = filtered.filter((node) =>
|
|
node.tags.some((tag) => tag.name.toLowerCase().includes(lowerFilter))
|
|
);
|
|
}
|
|
|
|
return filtered;
|
|
}, [graphData, showOrphans, statusFilter, tagFilter]);
|
|
|
|
// Filter edges to only include those between visible nodes
|
|
const filteredEdges = useMemo(() => {
|
|
if (!graphData) return [];
|
|
|
|
const nodeIds = new Set(filteredNodes.map((n) => n.id));
|
|
return graphData.edges.filter(
|
|
(edge) => nodeIds.has(edge.sourceId) && nodeIds.has(edge.targetId)
|
|
);
|
|
}, [graphData, filteredNodes]);
|
|
|
|
// Convert to ReactFlow format
|
|
const initialNodes = useMemo(
|
|
() => convertToReactFlowNodes(filteredNodes, filteredEdges, layout),
|
|
[filteredNodes, filteredEdges, layout]
|
|
);
|
|
|
|
const initialEdges = useMemo(() => convertToReactFlowEdges(filteredEdges), [filteredEdges]);
|
|
|
|
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
|
|
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
|
|
|
|
// Update nodes when layout or data changes
|
|
useEffect(() => {
|
|
const updateLayout = async (): Promise<void> => {
|
|
let newNodes = convertToReactFlowNodes(filteredNodes, filteredEdges, layout);
|
|
|
|
if (layout === "hierarchical") {
|
|
newNodes = await applyElkLayout(newNodes, initialEdges);
|
|
}
|
|
|
|
setNodes(newNodes);
|
|
};
|
|
|
|
void updateLayout();
|
|
}, [filteredNodes, filteredEdges, layout, initialEdges, setNodes]);
|
|
|
|
// Update edges when data changes
|
|
useEffect(() => {
|
|
setEdges(initialEdges);
|
|
}, [initialEdges, setEdges]);
|
|
|
|
// Handle node click - navigate to entry
|
|
const handleNodeClick = useCallback(
|
|
(_event: React.MouseEvent, node: Node): void => {
|
|
const slug = node.data.slug as string;
|
|
if (slug) {
|
|
router.push(`/knowledge/${slug}`);
|
|
}
|
|
},
|
|
[router]
|
|
);
|
|
|
|
// Handle layout change
|
|
const handleLayoutChange = useCallback((newLayout: LayoutType): void => {
|
|
setLayout(newLayout);
|
|
}, []);
|
|
|
|
if (isLoading) {
|
|
return (
|
|
<div className="flex items-center justify-center h-screen">
|
|
<div
|
|
data-testid="loading-spinner"
|
|
className="animate-spin rounded-full h-12 w-12 border-b-2 border-blue-500"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (error || !graphData) {
|
|
return (
|
|
<div className="flex flex-col items-center justify-center h-screen p-8">
|
|
<div className="text-red-500 text-xl font-semibold mb-2">Error Loading Graph</div>
|
|
<div className="text-sm text-gray-500 dark:text-gray-400">{error}</div>
|
|
<button
|
|
onClick={loadGraph}
|
|
className="mt-4 px-4 py-2 bg-blue-500 text-white rounded hover:bg-blue-600"
|
|
>
|
|
Retry
|
|
</button>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="h-screen flex flex-col bg-white dark:bg-gray-900">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800">
|
|
<div className="flex items-center gap-4">
|
|
<h2 className="text-xl font-bold text-gray-900 dark:text-gray-100">Knowledge Graph</h2>
|
|
<div className="text-sm text-gray-500 dark:text-gray-400">
|
|
{filteredNodes.length} entries • {filteredEdges.length} connections
|
|
{graphData.stats.orphanCount > 0 && (
|
|
<span className="ml-2">• {graphData.stats.orphanCount} orphaned</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Layout Controls */}
|
|
<div className="flex items-center gap-3">
|
|
<label className="text-sm font-medium text-gray-700 dark:text-gray-300">Layout:</label>
|
|
<div className="flex rounded-lg overflow-hidden border border-gray-200 dark:border-gray-700">
|
|
{(["force", "hierarchical", "circular"] as const).map((layoutType) => (
|
|
<button
|
|
key={layoutType}
|
|
onClick={() => {
|
|
handleLayoutChange(layoutType);
|
|
}}
|
|
className={`px-3 py-1.5 text-sm font-medium transition-colors capitalize ${
|
|
layout === layoutType
|
|
? "bg-blue-500 text-white"
|
|
: "bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700"
|
|
}`}
|
|
>
|
|
{layoutType}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Filters */}
|
|
<div className="flex items-center gap-4 p-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-800">
|
|
{/* Status Filter */}
|
|
<div className="flex items-center gap-2">
|
|
<label
|
|
htmlFor="status-filter"
|
|
className="text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
>
|
|
Status:
|
|
</label>
|
|
<select
|
|
id="status-filter"
|
|
value={statusFilter}
|
|
onChange={(e) => {
|
|
setStatusFilter(e.target.value);
|
|
}}
|
|
className="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
|
>
|
|
<option value="">All</option>
|
|
<option value="PUBLISHED">Published</option>
|
|
<option value="DRAFT">Draft</option>
|
|
<option value="ARCHIVED">Archived</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* Tag Filter */}
|
|
<div className="flex items-center gap-2">
|
|
<label
|
|
htmlFor="tag-filter"
|
|
className="text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
>
|
|
Tags:
|
|
</label>
|
|
<input
|
|
id="tag-filter"
|
|
type="text"
|
|
value={tagFilter}
|
|
onChange={(e) => {
|
|
setTagFilter(e.target.value);
|
|
}}
|
|
placeholder="Filter by tags..."
|
|
className="px-3 py-1.5 text-sm border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"
|
|
/>
|
|
</div>
|
|
|
|
{/* Orphan Toggle */}
|
|
<div className="flex items-center gap-2 ml-auto">
|
|
<label
|
|
htmlFor="orphan-toggle"
|
|
className="text-sm font-medium text-gray-700 dark:text-gray-300"
|
|
>
|
|
Show Orphans:
|
|
</label>
|
|
<button
|
|
id="orphan-toggle"
|
|
role="switch"
|
|
aria-checked={showOrphans}
|
|
onClick={() => {
|
|
setShowOrphans(!showOrphans);
|
|
}}
|
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${
|
|
showOrphans ? "bg-blue-500" : "bg-gray-300 dark:bg-gray-600"
|
|
}`}
|
|
>
|
|
<span
|
|
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
|
|
showOrphans ? "translate-x-6" : "translate-x-1"
|
|
}`}
|
|
/>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Graph Visualization */}
|
|
<div className="flex-1">
|
|
<ReactFlow
|
|
nodes={nodes}
|
|
edges={edges}
|
|
onNodesChange={onNodesChange}
|
|
onEdgesChange={onEdgesChange}
|
|
onNodeClick={handleNodeClick}
|
|
fitView
|
|
minZoom={0.1}
|
|
maxZoom={2}
|
|
defaultViewport={{ x: 0, y: 0, zoom: 0.8 }}
|
|
>
|
|
<Background variant={BackgroundVariant.Dots} gap={12} size={1} />
|
|
<Controls />
|
|
<MiniMap
|
|
nodeColor={(node) => {
|
|
const bgColor = node.style?.backgroundColor;
|
|
return typeof bgColor === "string" ? bgColor : "#3B82F6";
|
|
}}
|
|
maskColor="rgba(0, 0, 0, 0.1)"
|
|
/>
|
|
<Panel
|
|
position="bottom-left"
|
|
className="bg-white dark:bg-gray-800 p-3 rounded-lg shadow-lg"
|
|
>
|
|
<div className="text-xs text-gray-600 dark:text-gray-400 space-y-1">
|
|
<div className="flex items-center gap-2">
|
|
<div
|
|
className="w-3 h-3 rounded-full"
|
|
style={{ backgroundColor: STATUS_COLORS.PUBLISHED }}
|
|
/>
|
|
<span>Published (Active)</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div
|
|
className="w-3 h-3 rounded-full"
|
|
style={{ backgroundColor: STATUS_COLORS.DRAFT }}
|
|
/>
|
|
<span>Draft (Upcoming)</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div
|
|
className="w-3 h-3 rounded-full"
|
|
style={{ backgroundColor: STATUS_COLORS.ARCHIVED }}
|
|
/>
|
|
<span>Archived (Dormant)</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-3 h-3 rounded-full" style={{ backgroundColor: ORPHAN_COLOR }} />
|
|
<span>Orphaned</span>
|
|
</div>
|
|
</div>
|
|
</Panel>
|
|
</ReactFlow>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|