/** * KnowledgeGraph — SVG-based visualization of entity relationships. * * Fetches relationships from GET /api/v1/graph/relationships and renders * Contacts/Companies as nodes (circles) connected by labeled edges. * Supports pan/zoom and click-to-inspect details. */ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Loader2, ZoomIn, ZoomOut, Maximize2 } from 'lucide-react'; import { fetchGraphRelationships, type GraphRelationship } from '@/api/knowledge'; import { Card } from '@/components/ui/Card'; import { EmptyState } from '@/components/ui/EmptyState'; import { Button } from '@/components/ui/Button'; interface GraphNode { id: string; entity_type: string; label: string; x: number; y: number; } interface GraphEdge { id: string; source: string; target: string; label: string; } interface SelectedNode { id: string; entity_type: string; label: string; relationships: GraphRelationship[]; } const NODE_RADIUS = 24; const WIDTH = 900; const HEIGHT = 560; const TYPE_COLORS: Record = { contact: '#3b82f6', company: '#10b981', email: '#8b5cf6', task: '#f59e0b', dms_file: '#ef4444', default: '#64748b', }; function typeColor(type: string): string { return TYPE_COLORS[type] ?? TYPE_COLORS.default; } function typeLabel(type: string): string { return type.replace(/_/g, ' '); } /** * Simple force-directed layout: nodes repel, edges attract. * Deterministic initial placement avoids layout jumps. */ function layoutNodes(relationships: GraphRelationship[]): { nodes: GraphNode[]; edges: GraphEdge[] } { const nodeMap = new Map(); const edges: GraphEdge[] = []; for (const rel of relationships) { const sourceKey = `${rel.source_type}:${rel.source_id}`; const targetKey = `${rel.target_type}:${rel.target_id}`; if (!nodeMap.has(sourceKey)) { nodeMap.set(sourceKey, { entity_type: rel.source_type, label: rel.source_type }); } if (!nodeMap.has(targetKey)) { nodeMap.set(targetKey, { entity_type: rel.target_type, label: rel.target_type }); } edges.push({ id: rel.id, source: sourceKey, target: targetKey, label: rel.relationship_type, }); } const keys = Array.from(nodeMap.keys()); const nodes: GraphNode[] = keys.map((key, i) => { const angle = (i / Math.max(keys.length, 1)) * Math.PI * 2; const radius = Math.min(220, 80 + (i % 5) * 40); return { id: key, entity_type: nodeMap.get(key)?.entity_type ?? 'unknown', label: nodeMap.get(key)?.label ?? key, x: WIDTH / 2 + Math.cos(angle) * radius, y: HEIGHT / 2 + Math.sin(angle) * radius, }; }); // Simple repulsion/attraction relaxation for (let iter = 0; iter < 80; iter++) { for (let i = 0; i < nodes.length; i++) { for (let j = i + 1; j < nodes.length; j++) { const dx = nodes[j].x - nodes[i].x; const dy = nodes[j].y - nodes[i].y; const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1); const force = 40 / (dist * dist); const fx = (dx / dist) * force; const fy = (dy / dist) * force; nodes[i].x -= fx; nodes[i].y -= fy; nodes[j].x += fx; nodes[j].y += fy; } } for (const edge of edges) { const s = nodes.find((n) => n.id === edge.source); const t = nodes.find((n) => n.id === edge.target); if (!s || !t) continue; const dx = t.x - s.x; const dy = t.y - s.y; const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 1); const force = (dist - 160) * 0.02; const fx = (dx / dist) * force; const fy = (dy / dist) * force; s.x += fx; s.y += fy; t.x -= fx; t.y -= fy; } } // Clamp to viewport with padding for (const n of nodes) { n.x = Math.min(Math.max(n.x, 60), WIDTH - 60); n.y = Math.min(Math.max(n.y, 60), HEIGHT - 60); } return { nodes, edges }; } export function KnowledgeGraph() { const { t } = useTranslation(); const [relationships, setRelationships] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [selected, setSelected] = useState(null); const [zoom, setZoom] = useState(1); const [pan, setPan] = useState({ x: 0, y: 0 }); const [dragging, setDragging] = useState(false); const dragStart = useRef<{ x: number; y: number; panX: number; panY: number } | null>(null); const load = useCallback(async () => { setLoading(true); setError(null); try { const result = await fetchGraphRelationships({ page_size: 200 }); setRelationships(result.items); } catch (err) { setError(err instanceof Error ? err.message : t('knowledge.graph.loadError')); } finally { setLoading(false); } }, [t]); useEffect(() => { void load(); }, [load]); const { nodes, edges } = useMemo(() => layoutNodes(relationships), [relationships]); const selectedRelationships = useMemo(() => { if (!selected) return []; return relationships.filter( (r) => `${r.source_type}:${r.source_id}` === selected.id || `${r.target_type}:${r.target_id}` === selected.id ); }, [selected, relationships]); const handleNodeClick = useCallback((node: GraphNode) => { setSelected(node); }, []); const handleWheel = useCallback((e: React.WheelEvent) => { const factor = e.deltaY > 0 ? 0.9 : 1.1; setZoom((z) => Math.min(Math.max(z * factor, 0.4), 3)); }, []); const handleMouseDown = useCallback((e: React.MouseEvent) => { if (e.button !== 0) return; setDragging(true); dragStart.current = { x: e.clientX, y: e.clientY, panX: pan.x, panY: pan.y }; }, [pan]); const handleMouseMove = useCallback( (e: React.MouseEvent) => { if (!dragging || !dragStart.current) return; const dx = e.clientX - dragStart.current.x; const dy = e.clientY - dragStart.current.y; setPan({ x: dragStart.current.panX + dx, y: dragStart.current.panY + dy }); }, [dragging] ); const handleMouseUp = useCallback(() => { setDragging(false); dragStart.current = null; }, []); const resetView = useCallback(() => { setZoom(1); setPan({ x: 0, y: 0 }); }, []); return ( } > {loading && (
)} {!loading && error && (

{typeof error === "string" ? error : (error as any)?.message || "Ein Fehler ist aufgetreten"}

)} {!loading && !error && relationships.length === 0 && ( )} {!loading && !error && relationships.length > 0 && (
{/* Edges */} {edges.map((edge) => { const source = nodes.find((n) => n.id === edge.source); const target = nodes.find((n) => n.id === edge.target); if (!source || !target) return null; const mx = (source.x + target.x) / 2; const my = (source.y + target.y) / 2; return ( {edge.label} ); })} {/* Nodes */} {nodes.map((node) => { const isSelected = selected?.id === node.id; return ( { e.stopPropagation(); handleNodeClick(node); }} className="cursor-pointer" role="button" aria-label={`${node.label} ${node.entity_type}`} > {node.label.length > 12 ? `${node.label.slice(0, 11)}…` : node.label} ); })}
{selected ? (

{selected.label}

{typeLabel(selected.entity_type)}

{t('knowledge.graph.relationships')}

{selectedRelationships.length === 0 && (

{t('knowledge.graph.noRelationships')}

)}
    {selectedRelationships.map((rel) => { const isSource = `${rel.source_type}:${rel.source_id}` === selected.id; const otherType = isSource ? rel.target_type : rel.source_type; return (
  • {rel.relationship_type} {typeLabel(otherType)}
  • ); })}
) : (
{t('knowledge.graph.selectHint')}
)}
)}
); }