Kraft102's picture
fix: sql.js Docker/Alpine compatibility layer for PatternMemory and FailureMemory
5a81b95
/**
* ╔═══════════════════════════════════════════════════════════════════════════╗
* β•‘ INTERACTIVE FORCE GRAPH β•‘
* ║═══════════════════════════════════════════════════════════════════════════║
* β•‘ Dynamic knowledge graph visualization with force-directed layout β•‘
* β•‘ Part of the Liquid UI Arsenal β•‘
* β•šβ•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•β•
*/
import { useRef, useCallback, useState, useEffect } from 'react';
import ForceGraph2D, { ForceGraphMethods, NodeObject, LinkObject } from 'react-force-graph-2d';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { cn } from '@/lib/utils';
import {
ZoomIn, ZoomOut, Maximize2, RefreshCw,
MousePointer2, Move, Info
} from 'lucide-react';
export interface GraphNode {
id: string;
label: string;
type?: string;
color?: string;
size?: number;
metadata?: Record<string, unknown>;
}
export interface GraphLink {
source: string;
target: string;
label?: string;
type?: string;
weight?: number;
}
export interface InteractiveForceGraphProps {
nodes: GraphNode[];
links: GraphLink[];
title?: string;
height?: number;
onNodeClick?: (node: GraphNode) => void;
onLinkClick?: (link: GraphLink) => void;
highlightNodes?: string[];
highlightLinks?: string[];
}
// Color palette for node types
const nodeColors: Record<string, string> = {
concept: '#3b82f6', // Blue
entity: '#10b981', // Green
event: '#f59e0b', // Amber
person: '#ec4899', // Pink
document: '#8b5cf6', // Purple
system: '#06b6d4', // Cyan
gap: '#ef4444', // Red
idea: '#f97316', // Orange
default: '#6b7280', // Gray
};
export function InteractiveForceGraph({
nodes,
links,
title = 'Knowledge Graph',
height = 400,
onNodeClick,
onLinkClick,
highlightNodes = [],
highlightLinks = [],
}: InteractiveForceGraphProps) {
const graphRef = useRef<ForceGraphMethods>();
const containerRef = useRef<HTMLDivElement>(null);
const [hoveredNode, setHoveredNode] = useState<GraphNode | null>(null);
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
const [dimensions, setDimensions] = useState({ width: 600, height });
// Update dimensions on resize
useEffect(() => {
const updateDimensions = () => {
if (containerRef.current) {
setDimensions({
width: containerRef.current.offsetWidth,
height: height,
});
}
};
updateDimensions();
window.addEventListener('resize', updateDimensions);
return () => window.removeEventListener('resize', updateDimensions);
}, [height]);
// Transform data for react-force-graph
const graphData = {
nodes: nodes.map(n => ({
...n,
color: n.color || nodeColors[n.type || 'default'] || nodeColors.default,
val: n.size || 1,
})),
links: links.map(l => ({
...l,
color: highlightLinks.includes(`${l.source}-${l.target}`) ? '#fbbf24' : 'rgba(255,255,255,0.2)',
})),
};
// Node rendering
const paintNode = useCallback((node: NodeObject, ctx: CanvasRenderingContext2D, globalScale: number) => {
const gNode = node as NodeObject & GraphNode;
const label = gNode.label || gNode.id;
const fontSize = 12 / globalScale;
const size = (gNode.size || 1) * 4;
const isHighlighted = highlightNodes.includes(gNode.id as string);
const isHovered = hoveredNode?.id === gNode.id;
const isSelected = selectedNode?.id === gNode.id;
// Node circle
ctx.beginPath();
ctx.arc(node.x!, node.y!, size, 0, 2 * Math.PI);
ctx.fillStyle = gNode.color || nodeColors.default;
if (isHighlighted || isHovered || isSelected) {
ctx.shadowColor = gNode.color || '#fff';
ctx.shadowBlur = 15;
}
ctx.fill();
ctx.shadowBlur = 0;
// Border for selected/hovered
if (isSelected) {
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2 / globalScale;
ctx.stroke();
} else if (isHovered) {
ctx.strokeStyle = 'rgba(255,255,255,0.5)';
ctx.lineWidth = 1 / globalScale;
ctx.stroke();
}
// Label
if (globalScale > 0.5 || isHovered || isSelected) {
ctx.font = `${fontSize}px ui-monospace, monospace`;
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillStyle = 'rgba(255,255,255,0.9)';
ctx.fillText(label.substring(0, 20), node.x!, node.y! + size + 2);
}
// Type badge
if ((isHovered || isSelected) && gNode.type) {
ctx.font = `${fontSize * 0.7}px ui-monospace, monospace`;
ctx.fillStyle = 'rgba(255,255,255,0.6)';
ctx.fillText(`[${gNode.type}]`, node.x!, node.y! + size + fontSize + 4);
}
}, [highlightNodes, hoveredNode, selectedNode]);
// Link rendering
const paintLink = useCallback((link: LinkObject, ctx: CanvasRenderingContext2D, globalScale: number) => {
const gLink = link as LinkObject & GraphLink;
const start = link.source as NodeObject;
const end = link.target as NodeObject;
if (!start.x || !start.y || !end.x || !end.y) return;
// Draw line
ctx.beginPath();
ctx.moveTo(start.x, start.y);
ctx.lineTo(end.x, end.y);
ctx.strokeStyle = gLink.color || 'rgba(255,255,255,0.2)';
ctx.lineWidth = (gLink.weight || 1) / globalScale;
ctx.stroke();
// Draw label if zoomed in
if (globalScale > 1 && gLink.label) {
const midX = (start.x + end.x) / 2;
const midY = (start.y + end.y) / 2;
const fontSize = 8 / globalScale;
ctx.font = `${fontSize}px ui-monospace, monospace`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillStyle = 'rgba(255,255,255,0.5)';
ctx.fillText(gLink.label, midX, midY);
}
}, []);
// Handlers
const handleNodeClick = useCallback((node: NodeObject) => {
const gNode = node as NodeObject & GraphNode;
setSelectedNode(gNode);
onNodeClick?.(gNode);
// Center on node
graphRef.current?.centerAt(node.x, node.y, 500);
graphRef.current?.zoom(2, 500);
}, [onNodeClick]);
const handleNodeHover = useCallback((node: NodeObject | null) => {
setHoveredNode(node as GraphNode | null);
if (containerRef.current) {
containerRef.current.style.cursor = node ? 'pointer' : 'grab';
}
}, []);
const handleLinkClick = useCallback((link: LinkObject) => {
const gLink = link as LinkObject & GraphLink;
onLinkClick?.(gLink);
}, [onLinkClick]);
// Controls
const zoomIn = () => graphRef.current?.zoom(graphRef.current?.zoom() * 1.5, 300);
const zoomOut = () => graphRef.current?.zoom(graphRef.current?.zoom() / 1.5, 300);
const resetView = () => {
graphRef.current?.zoomToFit(400);
setSelectedNode(null);
};
return (
<div className="rounded-lg border border-border/30 bg-background/50 overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-4 py-2 bg-muted/30 border-b border-border/30">
<div className="flex items-center gap-3">
<span className="text-sm font-medium">{title}</span>
<Badge variant="outline" className="text-[10px] font-mono">
{nodes.length} nodes / {links.length} links
</Badge>
</div>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={zoomIn} className="h-7 w-7 p-0">
<ZoomIn className="w-3 h-3" />
</Button>
<Button variant="ghost" size="sm" onClick={zoomOut} className="h-7 w-7 p-0">
<ZoomOut className="w-3 h-3" />
</Button>
<Button variant="ghost" size="sm" onClick={resetView} className="h-7 w-7 p-0">
<Maximize2 className="w-3 h-3" />
</Button>
</div>
</div>
{/* Graph container */}
<div ref={containerRef} className="relative bg-black/30" style={{ height }}>
<ForceGraph2D
ref={graphRef}
graphData={graphData}
width={dimensions.width}
height={dimensions.height}
backgroundColor="transparent"
nodeCanvasObject={paintNode}
linkCanvasObject={paintLink}
onNodeClick={handleNodeClick}
onNodeHover={handleNodeHover}
onLinkClick={handleLinkClick}
nodePointerAreaPaint={(node, color, ctx) => {
const size = ((node as GraphNode).size || 1) * 4 + 4;
ctx.fillStyle = color;
ctx.beginPath();
ctx.arc(node.x!, node.y!, size, 0, 2 * Math.PI);
ctx.fill();
}}
linkDirectionalArrowLength={4}
linkDirectionalArrowRelPos={1}
d3AlphaDecay={0.02}
d3VelocityDecay={0.3}
warmupTicks={50}
cooldownTicks={100}
/>
{/* Node info tooltip */}
{(hoveredNode || selectedNode) && (
<div className="absolute bottom-3 left-3 right-3 p-2 bg-black/80 rounded border border-border/30 text-xs">
<div className="flex items-center gap-2 mb-1">
<div
className="w-2 h-2 rounded-full"
style={{ backgroundColor: (hoveredNode || selectedNode)?.color }}
/>
<span className="font-medium">{(hoveredNode || selectedNode)?.label}</span>
{(hoveredNode || selectedNode)?.type && (
<Badge variant="outline" className="text-[8px]">
{(hoveredNode || selectedNode)?.type}
</Badge>
)}
</div>
<span className="text-muted-foreground font-mono text-[10px]">
ID: {(hoveredNode || selectedNode)?.id}
</span>
</div>
)}
</div>
{/* Legend */}
<div className="px-4 py-2 bg-muted/20 border-t border-border/30 flex items-center gap-4 flex-wrap text-[10px]">
{Object.entries(nodeColors).slice(0, 6).map(([type, color]) => (
<div key={type} className="flex items-center gap-1">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: color }} />
<span className="text-muted-foreground capitalize">{type}</span>
</div>
))}
</div>
</div>
);
}
export default InteractiveForceGraph;