370 lines
13 KiB
TypeScript
370 lines
13 KiB
TypeScript
|
|
/**
|
||
|
|
* 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<string, string> = {
|
||
|
|
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<string, { entity_type: string; label: string }>();
|
||
|
|
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<GraphRelationship[]>([]);
|
||
|
|
const [loading, setLoading] = useState(true);
|
||
|
|
const [error, setError] = useState<string | null>(null);
|
||
|
|
const [selected, setSelected] = useState<GraphNode | null>(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<SVGSVGElement>) => {
|
||
|
|
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<SVGSVGElement>) => {
|
||
|
|
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<SVGSVGElement>) => {
|
||
|
|
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 (
|
||
|
|
<Card
|
||
|
|
title={t('knowledge.graph.title')}
|
||
|
|
description={t('knowledge.graph.description')}
|
||
|
|
actions={
|
||
|
|
<div className="flex items-center gap-1">
|
||
|
|
<Button variant="ghost" size="sm" onClick={() => setZoom((z) => Math.min(z * 1.2, 3))} aria-label={t('knowledge.graph.zoomIn')}>
|
||
|
|
<ZoomIn className="h-4 w-4" aria-hidden="true" />
|
||
|
|
</Button>
|
||
|
|
<Button variant="ghost" size="sm" onClick={() => setZoom((z) => Math.max(z * 0.8, 0.4))} aria-label={t('knowledge.graph.zoomOut')}>
|
||
|
|
<ZoomOut className="h-4 w-4" aria-hidden="true" />
|
||
|
|
</Button>
|
||
|
|
<Button variant="ghost" size="sm" onClick={resetView} aria-label={t('knowledge.graph.reset')}>
|
||
|
|
<Maximize2 className="h-4 w-4" aria-hidden="true" />
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
}
|
||
|
|
>
|
||
|
|
{loading && (
|
||
|
|
<div className="flex items-center justify-center py-16" role="status" aria-label={t('common.loading')}>
|
||
|
|
<Loader2 className="animate-spin h-8 w-8 text-primary-500" aria-hidden="true" />
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
{!loading && error && (
|
||
|
|
<div className="py-8 text-center">
|
||
|
|
<p className="text-danger-600 text-sm mb-4">{error}</p>
|
||
|
|
<Button variant="secondary" onClick={() => void load()}>
|
||
|
|
{t('common.retry')}
|
||
|
|
</Button>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
{!loading && !error && relationships.length === 0 && (
|
||
|
|
<EmptyState
|
||
|
|
title={t('knowledge.graph.emptyTitle')}
|
||
|
|
description={t('knowledge.graph.emptyDescription')}
|
||
|
|
/>
|
||
|
|
)}
|
||
|
|
{!loading && !error && relationships.length > 0 && (
|
||
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||
|
|
<div className="lg:col-span-2 border border-secondary-200 rounded-lg overflow-hidden bg-secondary-50">
|
||
|
|
<svg
|
||
|
|
width="100%"
|
||
|
|
height={HEIGHT}
|
||
|
|
viewBox={`0 0 ${WIDTH} ${HEIGHT}`}
|
||
|
|
className="cursor-grab active:cursor-grabbing touch-none select-none"
|
||
|
|
onWheel={handleWheel}
|
||
|
|
onMouseDown={handleMouseDown}
|
||
|
|
onMouseMove={handleMouseMove}
|
||
|
|
onMouseUp={handleMouseUp}
|
||
|
|
onMouseLeave={handleMouseUp}
|
||
|
|
role="img"
|
||
|
|
aria-label={t('knowledge.graph.ariaLabel')}
|
||
|
|
>
|
||
|
|
<g transform={`translate(${pan.x}, ${pan.y}) scale(${zoom})`}>
|
||
|
|
{/* 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 (
|
||
|
|
<g key={edge.id}>
|
||
|
|
<line
|
||
|
|
x1={source.x}
|
||
|
|
y1={source.y}
|
||
|
|
x2={target.x}
|
||
|
|
y2={target.y}
|
||
|
|
stroke="#94a3b8"
|
||
|
|
strokeWidth={1.5}
|
||
|
|
/>
|
||
|
|
<text
|
||
|
|
x={mx}
|
||
|
|
y={my - 6}
|
||
|
|
textAnchor="middle"
|
||
|
|
fontSize={11}
|
||
|
|
fill="#64748b"
|
||
|
|
className="pointer-events-none"
|
||
|
|
>
|
||
|
|
{edge.label}
|
||
|
|
</text>
|
||
|
|
</g>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
{/* Nodes */}
|
||
|
|
{nodes.map((node) => {
|
||
|
|
const isSelected = selected?.id === node.id;
|
||
|
|
return (
|
||
|
|
<g
|
||
|
|
key={node.id}
|
||
|
|
transform={`translate(${node.x}, ${node.y})`}
|
||
|
|
onClick={(e) => {
|
||
|
|
e.stopPropagation();
|
||
|
|
handleNodeClick(node);
|
||
|
|
}}
|
||
|
|
className="cursor-pointer"
|
||
|
|
role="button"
|
||
|
|
aria-label={`${node.label} ${node.entity_type}`}
|
||
|
|
>
|
||
|
|
<circle
|
||
|
|
r={NODE_RADIUS}
|
||
|
|
fill={typeColor(node.entity_type)}
|
||
|
|
fillOpacity={isSelected ? 1 : 0.85}
|
||
|
|
stroke={isSelected ? '#0f172a' : '#ffffff'}
|
||
|
|
strokeWidth={isSelected ? 3 : 2}
|
||
|
|
/>
|
||
|
|
<text
|
||
|
|
textAnchor="middle"
|
||
|
|
dy="0.35em"
|
||
|
|
fontSize={11}
|
||
|
|
fill="#ffffff"
|
||
|
|
fontWeight={600}
|
||
|
|
className="pointer-events-none"
|
||
|
|
>
|
||
|
|
{node.label.length > 12 ? `${node.label.slice(0, 11)}…` : node.label}
|
||
|
|
</text>
|
||
|
|
</g>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</g>
|
||
|
|
</svg>
|
||
|
|
</div>
|
||
|
|
<div>
|
||
|
|
{selected ? (
|
||
|
|
<div className="border border-secondary-200 rounded-lg p-4 bg-white">
|
||
|
|
<h4 className="font-semibold text-secondary-900 mb-1">{selected.label}</h4>
|
||
|
|
<p className="text-sm text-secondary-500 mb-3">{typeLabel(selected.entity_type)}</p>
|
||
|
|
<p className="text-sm font-medium text-secondary-700 mb-2">{t('knowledge.graph.relationships')}</p>
|
||
|
|
{selectedRelationships.length === 0 && (
|
||
|
|
<p className="text-sm text-secondary-400">{t('knowledge.graph.noRelationships')}</p>
|
||
|
|
)}
|
||
|
|
<ul className="space-y-2">
|
||
|
|
{selectedRelationships.map((rel) => {
|
||
|
|
const isSource = `${rel.source_type}:${rel.source_id}` === selected.id;
|
||
|
|
const otherType = isSource ? rel.target_type : rel.source_type;
|
||
|
|
return (
|
||
|
|
<li key={rel.id} className="text-sm text-secondary-700">
|
||
|
|
<span className="font-medium">{rel.relationship_type}</span>
|
||
|
|
<span className="text-secondary-400"> → </span>
|
||
|
|
<span>{typeLabel(otherType)}</span>
|
||
|
|
</li>
|
||
|
|
);
|
||
|
|
})}
|
||
|
|
</ul>
|
||
|
|
</div>
|
||
|
|
) : (
|
||
|
|
<div className="border border-secondary-200 rounded-lg p-4 bg-white text-sm text-secondary-500">
|
||
|
|
{t('knowledge.graph.selectHint')}
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</div>
|
||
|
|
</div>
|
||
|
|
)}
|
||
|
|
</Card>
|
||
|
|
);
|
||
|
|
}
|