claude-cortex 1.0.0 → 1.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (79) hide show
  1. package/dashboard/README.md +36 -0
  2. package/dashboard/components.json +22 -0
  3. package/dashboard/eslint.config.mjs +18 -0
  4. package/dashboard/next.config.ts +7 -0
  5. package/dashboard/package-lock.json +7784 -0
  6. package/dashboard/package.json +42 -0
  7. package/dashboard/postcss.config.mjs +7 -0
  8. package/dashboard/public/file.svg +1 -0
  9. package/dashboard/public/globe.svg +1 -0
  10. package/dashboard/public/next.svg +1 -0
  11. package/dashboard/public/vercel.svg +1 -0
  12. package/dashboard/public/window.svg +1 -0
  13. package/dashboard/src/app/favicon.ico +0 -0
  14. package/dashboard/src/app/globals.css +125 -0
  15. package/dashboard/src/app/layout.tsx +35 -0
  16. package/dashboard/src/app/page.tsx +338 -0
  17. package/dashboard/src/components/Providers.tsx +27 -0
  18. package/dashboard/src/components/brain/ActivityPulseSystem.tsx +229 -0
  19. package/dashboard/src/components/brain/BrainMesh.tsx +118 -0
  20. package/dashboard/src/components/brain/BrainRegions.tsx +254 -0
  21. package/dashboard/src/components/brain/BrainScene.tsx +255 -0
  22. package/dashboard/src/components/brain/CategoryLabels.tsx +103 -0
  23. package/dashboard/src/components/brain/CoreSphere.tsx +215 -0
  24. package/dashboard/src/components/brain/DataFlowParticles.tsx +123 -0
  25. package/dashboard/src/components/brain/DataStreamRings.tsx +161 -0
  26. package/dashboard/src/components/brain/ElectronFlow.tsx +323 -0
  27. package/dashboard/src/components/brain/HolographicGrid.tsx +235 -0
  28. package/dashboard/src/components/brain/MemoryLinks.tsx +271 -0
  29. package/dashboard/src/components/brain/MemoryNode.tsx +245 -0
  30. package/dashboard/src/components/brain/NeuralPathways.tsx +441 -0
  31. package/dashboard/src/components/brain/SynapseNodes.tsx +306 -0
  32. package/dashboard/src/components/brain/TimelineControls.tsx +205 -0
  33. package/dashboard/src/components/chip/ChipScene.tsx +497 -0
  34. package/dashboard/src/components/chip/ChipSubstrate.tsx +238 -0
  35. package/dashboard/src/components/chip/CortexCore.tsx +210 -0
  36. package/dashboard/src/components/chip/DataBus.tsx +416 -0
  37. package/dashboard/src/components/chip/MemoryCell.tsx +225 -0
  38. package/dashboard/src/components/chip/MemoryGrid.tsx +328 -0
  39. package/dashboard/src/components/chip/QuantumCell.tsx +316 -0
  40. package/dashboard/src/components/chip/SectionLabel.tsx +113 -0
  41. package/dashboard/src/components/chip/index.ts +14 -0
  42. package/dashboard/src/components/controls/ControlPanel.tsx +100 -0
  43. package/dashboard/src/components/dashboard/StatsPanel.tsx +164 -0
  44. package/dashboard/src/components/debug/ActivityLog.tsx +238 -0
  45. package/dashboard/src/components/debug/DebugPanel.tsx +101 -0
  46. package/dashboard/src/components/debug/QueryTester.tsx +192 -0
  47. package/dashboard/src/components/debug/RelationshipGraph.tsx +403 -0
  48. package/dashboard/src/components/debug/SqlConsole.tsx +313 -0
  49. package/dashboard/src/components/memory/MemoryDetail.tsx +325 -0
  50. package/dashboard/src/components/ui/button.tsx +62 -0
  51. package/dashboard/src/components/ui/card.tsx +92 -0
  52. package/dashboard/src/components/ui/input.tsx +21 -0
  53. package/dashboard/src/hooks/useDebouncedValue.ts +24 -0
  54. package/dashboard/src/hooks/useMemories.ts +276 -0
  55. package/dashboard/src/hooks/useSuggestions.ts +46 -0
  56. package/dashboard/src/lib/category-colors.ts +84 -0
  57. package/dashboard/src/lib/position-algorithm.ts +177 -0
  58. package/dashboard/src/lib/simplex-noise.ts +217 -0
  59. package/dashboard/src/lib/store.ts +88 -0
  60. package/dashboard/src/lib/utils.ts +6 -0
  61. package/dashboard/src/lib/websocket.ts +216 -0
  62. package/dashboard/src/types/memory.ts +73 -0
  63. package/dashboard/tsconfig.json +34 -0
  64. package/dist/api/control.d.ts +27 -0
  65. package/dist/api/control.d.ts.map +1 -0
  66. package/dist/api/control.js +60 -0
  67. package/dist/api/control.js.map +1 -0
  68. package/dist/api/visualization-server.d.ts.map +1 -1
  69. package/dist/api/visualization-server.js +109 -2
  70. package/dist/api/visualization-server.js.map +1 -1
  71. package/dist/index.d.ts +2 -1
  72. package/dist/index.d.ts.map +1 -1
  73. package/dist/index.js +80 -4
  74. package/dist/index.js.map +1 -1
  75. package/dist/memory/store.d.ts +6 -0
  76. package/dist/memory/store.d.ts.map +1 -1
  77. package/dist/memory/store.js +14 -0
  78. package/dist/memory/store.js.map +1 -1
  79. package/package.json +7 -3
@@ -0,0 +1,101 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Debug Panel Component
5
+ *
6
+ * Collapsible bottom panel with tabbed interface for debug tools:
7
+ * - Memory Detail (enhanced)
8
+ * - Query Tester
9
+ * - Activity Log
10
+ * - Relationship Graph
11
+ * - SQL Console
12
+ */
13
+
14
+ import { useState } from 'react';
15
+ import { QueryTester } from './QueryTester';
16
+ import { ActivityLog } from './ActivityLog';
17
+ import { RelationshipGraph } from './RelationshipGraph';
18
+ import { SqlConsole } from './SqlConsole';
19
+
20
+ type TabId = 'detail' | 'query' | 'activity' | 'graph' | 'sql';
21
+
22
+ interface Tab {
23
+ id: TabId;
24
+ label: string;
25
+ icon: string;
26
+ }
27
+
28
+ const TABS: Tab[] = [
29
+ { id: 'query', label: 'Query', icon: '🔍' },
30
+ { id: 'activity', label: 'Activity', icon: '📋' },
31
+ { id: 'graph', label: 'Graph', icon: '🕸' },
32
+ { id: 'sql', label: 'SQL', icon: '💾' },
33
+ ];
34
+
35
+ interface DebugPanelProps {
36
+ onCollapse?: () => void;
37
+ }
38
+
39
+ export function DebugPanel({ onCollapse }: DebugPanelProps) {
40
+ const [activeTab, setActiveTab] = useState<TabId>('query');
41
+ const [isCollapsed, setIsCollapsed] = useState(false);
42
+
43
+ const handleCollapse = () => {
44
+ setIsCollapsed(!isCollapsed);
45
+ onCollapse?.();
46
+ };
47
+
48
+ if (isCollapsed) {
49
+ return (
50
+ <div className="h-10 border-t border-slate-700 bg-slate-900/80 flex items-center px-4">
51
+ <button
52
+ onClick={handleCollapse}
53
+ className="flex items-center gap-2 text-sm text-slate-400 hover:text-white"
54
+ >
55
+ <span>▲</span>
56
+ <span>Debug Panel</span>
57
+ </button>
58
+ </div>
59
+ );
60
+ }
61
+
62
+ return (
63
+ <div className="h-96 border-t border-slate-700 bg-slate-900/80 flex flex-col">
64
+ {/* Tab Bar */}
65
+ <div className="flex items-center border-b border-slate-700 px-2">
66
+ {TABS.map((tab) => (
67
+ <button
68
+ key={tab.id}
69
+ onClick={() => setActiveTab(tab.id)}
70
+ className={`px-4 py-2 text-sm flex items-center gap-1.5 border-b-2 transition-colors ${
71
+ activeTab === tab.id
72
+ ? 'text-white border-blue-500'
73
+ : 'text-slate-400 border-transparent hover:text-white hover:border-slate-600'
74
+ }`}
75
+ >
76
+ <span>{tab.icon}</span>
77
+ <span>{tab.label}</span>
78
+ </button>
79
+ ))}
80
+
81
+ <div className="flex-1" />
82
+
83
+ <button
84
+ onClick={handleCollapse}
85
+ className="p-2 text-slate-400 hover:text-white"
86
+ title="Collapse panel"
87
+ >
88
+
89
+ </button>
90
+ </div>
91
+
92
+ {/* Tab Content */}
93
+ <div className="flex-1 overflow-hidden min-h-0">
94
+ {activeTab === 'query' && <QueryTester />}
95
+ {activeTab === 'activity' && <ActivityLog />}
96
+ {activeTab === 'graph' && <RelationshipGraph />}
97
+ {activeTab === 'sql' && <SqlConsole />}
98
+ </div>
99
+ </div>
100
+ );
101
+ }
@@ -0,0 +1,192 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Query Tester Component
5
+ *
6
+ * Allows testing search queries against the memory system
7
+ * with detailed score breakdowns and explanations.
8
+ */
9
+
10
+ import { useState } from 'react';
11
+ import { Button } from '@/components/ui/button';
12
+ import { Input } from '@/components/ui/input';
13
+ import { Memory } from '@/types/memory';
14
+
15
+ const API_BASE = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3001';
16
+
17
+ interface SearchResult {
18
+ memory: Memory & { decayedScore: number };
19
+ relevanceScore: number;
20
+ }
21
+
22
+ type SearchMode = 'hybrid' | 'fts' | 'vector';
23
+
24
+ export function QueryTester() {
25
+ const [query, setQuery] = useState('');
26
+ const [mode, setMode] = useState<SearchMode>('hybrid');
27
+ const [results, setResults] = useState<SearchResult[]>([]);
28
+ const [isSearching, setIsSearching] = useState(false);
29
+ const [error, setError] = useState<string | null>(null);
30
+
31
+ const handleSearch = async () => {
32
+ if (!query.trim()) return;
33
+
34
+ setIsSearching(true);
35
+ setError(null);
36
+
37
+ try {
38
+ const params = new URLSearchParams({
39
+ query: query.trim(),
40
+ mode: 'search',
41
+ limit: '20',
42
+ });
43
+
44
+ const response = await fetch(`${API_BASE}/api/memories?${params}`);
45
+ if (!response.ok) {
46
+ throw new Error(`Search failed: ${response.statusText}`);
47
+ }
48
+
49
+ const data = await response.json();
50
+ // Transform API response to include relevance score
51
+ const resultsWithScore: SearchResult[] = data.memories.map((m: Memory & { decayedScore: number }) => ({
52
+ memory: m,
53
+ relevanceScore: m.decayedScore, // Using decayedScore as proxy for relevance
54
+ }));
55
+
56
+ setResults(resultsWithScore);
57
+ } catch (err) {
58
+ setError((err as Error).message);
59
+ setResults([]);
60
+ } finally {
61
+ setIsSearching(false);
62
+ }
63
+ };
64
+
65
+ const handleKeyDown = (e: React.KeyboardEvent) => {
66
+ if (e.key === 'Enter') {
67
+ handleSearch();
68
+ }
69
+ };
70
+
71
+ return (
72
+ <div className="h-full flex flex-col">
73
+ {/* Search Controls */}
74
+ <div className="p-3 border-b border-slate-700 flex gap-2 items-center">
75
+ <Input
76
+ type="text"
77
+ placeholder="Enter search query..."
78
+ value={query}
79
+ onChange={(e) => setQuery(e.target.value)}
80
+ onKeyDown={handleKeyDown}
81
+ className="flex-1 bg-slate-800 border-slate-600 text-white"
82
+ />
83
+
84
+ {/* Mode Toggle */}
85
+ <div className="flex gap-1 bg-slate-800 rounded-lg p-1">
86
+ {(['hybrid', 'fts', 'vector'] as SearchMode[]).map((m) => (
87
+ <button
88
+ key={m}
89
+ onClick={() => setMode(m)}
90
+ className={`px-2 py-1 text-xs rounded transition-colors ${
91
+ mode === m
92
+ ? 'bg-blue-600 text-white'
93
+ : 'text-slate-400 hover:text-white'
94
+ }`}
95
+ >
96
+ {m.toUpperCase()}
97
+ </button>
98
+ ))}
99
+ </div>
100
+
101
+ <Button
102
+ onClick={handleSearch}
103
+ disabled={isSearching || !query.trim()}
104
+ size="sm"
105
+ className="bg-blue-600 hover:bg-blue-700"
106
+ >
107
+ {isSearching ? 'Searching...' : 'Search'}
108
+ </Button>
109
+ </div>
110
+
111
+ {/* Results */}
112
+ <div className="flex-1 overflow-auto p-3">
113
+ {error && (
114
+ <div className="p-3 rounded-lg bg-red-500/20 border border-red-500/50 text-red-300 text-sm mb-3">
115
+ {error}
116
+ </div>
117
+ )}
118
+
119
+ {results.length === 0 && !error && (
120
+ <div className="text-slate-500 text-sm text-center py-8">
121
+ {query ? 'No results found' : 'Enter a query to search memories'}
122
+ </div>
123
+ )}
124
+
125
+ {results.length > 0 && (
126
+ <div className="space-y-2">
127
+ <div className="text-xs text-slate-400 mb-2">
128
+ Found {results.length} results
129
+ </div>
130
+
131
+ <table className="w-full text-sm">
132
+ <thead>
133
+ <tr className="text-left text-slate-400 border-b border-slate-700">
134
+ <th className="pb-2 pr-4">Title</th>
135
+ <th className="pb-2 pr-4 w-20">Score</th>
136
+ <th className="pb-2 pr-4 w-24">Type</th>
137
+ <th className="pb-2 w-24">Category</th>
138
+ </tr>
139
+ </thead>
140
+ <tbody>
141
+ {results.map((result, index) => (
142
+ <tr
143
+ key={result.memory.id}
144
+ className="border-b border-slate-800 hover:bg-slate-800/50"
145
+ >
146
+ <td className="py-2 pr-4">
147
+ <div className="flex items-center gap-2">
148
+ <span className="text-slate-500 text-xs w-5">
149
+ {index + 1}.
150
+ </span>
151
+ <span className="text-white truncate max-w-[300px]">
152
+ {result.memory.title}
153
+ </span>
154
+ </div>
155
+ </td>
156
+ <td className="py-2 pr-4">
157
+ <div className="flex items-center gap-1">
158
+ <div
159
+ className="h-1.5 rounded-full bg-gradient-to-r from-blue-600 to-purple-600"
160
+ style={{ width: `${result.relevanceScore * 100}%`, maxWidth: '60px' }}
161
+ />
162
+ <span className="text-xs text-slate-400">
163
+ {(result.relevanceScore * 100).toFixed(0)}%
164
+ </span>
165
+ </div>
166
+ </td>
167
+ <td className="py-2 pr-4">
168
+ <span className={`text-xs px-1.5 py-0.5 rounded ${
169
+ result.memory.type === 'long_term'
170
+ ? 'bg-purple-600/30 text-purple-300'
171
+ : result.memory.type === 'short_term'
172
+ ? 'bg-blue-600/30 text-blue-300'
173
+ : 'bg-green-600/30 text-green-300'
174
+ }`}>
175
+ {result.memory.type.replace('_', '-')}
176
+ </span>
177
+ </td>
178
+ <td className="py-2">
179
+ <span className="text-xs text-slate-400">
180
+ {result.memory.category}
181
+ </span>
182
+ </td>
183
+ </tr>
184
+ ))}
185
+ </tbody>
186
+ </table>
187
+ </div>
188
+ )}
189
+ </div>
190
+ </div>
191
+ );
192
+ }
@@ -0,0 +1,403 @@
1
+ 'use client';
2
+
3
+ /**
4
+ * Relationship Graph Component - Focus Mode
5
+ *
6
+ * Clean visualization: click a memory to see its direct connections.
7
+ * Unselected state shows all nodes dimmed, selected shows focus view.
8
+ */
9
+
10
+ import { useEffect, useRef, useState, useMemo } from 'react';
11
+ import { useMemoryLinks, useMemories } from '@/hooks/useMemories';
12
+ import { Memory, MemoryLink } from '@/types/memory';
13
+
14
+ interface Node {
15
+ id: number;
16
+ title: string;
17
+ category: string;
18
+ salience: number;
19
+ x: number;
20
+ y: number;
21
+ }
22
+
23
+ interface Edge {
24
+ source: number;
25
+ target: number;
26
+ relationship: string;
27
+ strength: number;
28
+ }
29
+
30
+ const RELATIONSHIP_COLORS: Record<string, string> = {
31
+ related: '#6366f1',
32
+ extends: '#22c55e',
33
+ references: '#3b82f6',
34
+ contradicts: '#ef4444',
35
+ };
36
+
37
+ const CATEGORY_COLORS: Record<string, string> = {
38
+ architecture: '#8b5cf6',
39
+ pattern: '#3b82f6',
40
+ error: '#ef4444',
41
+ learning: '#22c55e',
42
+ preference: '#f59e0b',
43
+ context: '#6366f1',
44
+ todo: '#ec4899',
45
+ note: '#64748b',
46
+ relationship: '#14b8a6',
47
+ custom: '#64748b',
48
+ };
49
+
50
+ export function RelationshipGraph() {
51
+ const canvasRef = useRef<HTMLCanvasElement>(null);
52
+ const [selectedNodeId, setSelectedNodeId] = useState<number | null>(null);
53
+ const [hoveredNodeId, setHoveredNodeId] = useState<number | null>(null);
54
+ const [relationshipFilter, setRelationshipFilter] = useState<string | null>(null);
55
+
56
+ const { data: links = [] } = useMemoryLinks();
57
+ const { data: memories = [] } = useMemories({ limit: 200 });
58
+
59
+ // Build graph data with stable positions
60
+ const { nodes, edges, nodeMap } = useMemo(() => {
61
+ const linkedIds = new Set<number>();
62
+ for (const link of links) {
63
+ linkedIds.add(link.source_id);
64
+ linkedIds.add(link.target_id);
65
+ }
66
+
67
+ const linkedMemories = memories.filter((m) => linkedIds.has(m.id));
68
+
69
+ // Create stable grid layout
70
+ const cols = Math.ceil(Math.sqrt(linkedMemories.length));
71
+ const cellWidth = 100;
72
+ const cellHeight = 80;
73
+
74
+ const nodes: Node[] = linkedMemories.map((m, i) => ({
75
+ id: m.id,
76
+ title: m.title,
77
+ category: m.category,
78
+ salience: m.salience,
79
+ x: (i % cols) * cellWidth + cellWidth / 2 + 50,
80
+ y: Math.floor(i / cols) * cellHeight + cellHeight / 2 + 50,
81
+ }));
82
+
83
+ const edges: Edge[] = links.map((l) => ({
84
+ source: l.source_id,
85
+ target: l.target_id,
86
+ relationship: l.relationship,
87
+ strength: l.strength,
88
+ }));
89
+
90
+ const nodeMap = new Map(nodes.map((n) => [n.id, n]));
91
+
92
+ return { nodes, edges, nodeMap };
93
+ }, [memories, links]);
94
+
95
+ // Filter edges
96
+ const filteredEdges = relationshipFilter
97
+ ? edges.filter((e) => e.relationship === relationshipFilter)
98
+ : edges;
99
+
100
+ // Get connections for selected node
101
+ const selectedConnections = useMemo(() => {
102
+ if (!selectedNodeId) return { connectedIds: new Set<number>(), connectedEdges: [] };
103
+
104
+ const connectedIds = new Set<number>();
105
+ const connectedEdges: Edge[] = [];
106
+
107
+ for (const edge of filteredEdges) {
108
+ if (edge.source === selectedNodeId) {
109
+ connectedIds.add(edge.target);
110
+ connectedEdges.push(edge);
111
+ } else if (edge.target === selectedNodeId) {
112
+ connectedIds.add(edge.source);
113
+ connectedEdges.push(edge);
114
+ }
115
+ }
116
+
117
+ return { connectedIds, connectedEdges };
118
+ }, [selectedNodeId, filteredEdges]);
119
+
120
+ // Get unique relationship types
121
+ const relationshipTypes = [...new Set(edges.map((e) => e.relationship))];
122
+
123
+ // Get selected node details
124
+ const selectedNode = selectedNodeId ? nodeMap.get(selectedNodeId) : null;
125
+
126
+ // Canvas rendering
127
+ useEffect(() => {
128
+ const canvas = canvasRef.current;
129
+ if (!canvas) return;
130
+
131
+ const ctx = canvas.getContext('2d');
132
+ if (!ctx) return;
133
+
134
+ const rect = canvas.getBoundingClientRect();
135
+ canvas.width = rect.width * window.devicePixelRatio;
136
+ canvas.height = rect.height * window.devicePixelRatio;
137
+ ctx.scale(window.devicePixelRatio, window.devicePixelRatio);
138
+
139
+ const width = rect.width;
140
+ const height = rect.height;
141
+
142
+ // Clear
143
+ ctx.fillStyle = '#0f172a';
144
+ ctx.fillRect(0, 0, width, height);
145
+
146
+ if (nodes.length === 0) return;
147
+
148
+ // Calculate layout to fit in canvas
149
+ const padding = 60;
150
+ const cols = Math.ceil(Math.sqrt(nodes.length));
151
+ const rows = Math.ceil(nodes.length / cols);
152
+ const cellWidth = (width - padding * 2) / cols;
153
+ const cellHeight = (height - padding * 2) / rows;
154
+
155
+ // Update positions to fit canvas
156
+ nodes.forEach((node, i) => {
157
+ node.x = (i % cols) * cellWidth + cellWidth / 2 + padding;
158
+ node.y = Math.floor(i / cols) * cellHeight + cellHeight / 2 + padding;
159
+ });
160
+
161
+ const { connectedIds, connectedEdges } = selectedConnections;
162
+
163
+ // Draw edges (only for selected node, or all dimmed if none selected)
164
+ if (selectedNodeId) {
165
+ // Draw connected edges prominently
166
+ ctx.lineWidth = 2;
167
+ for (const edge of connectedEdges) {
168
+ const source = nodeMap.get(edge.source);
169
+ const target = nodeMap.get(edge.target);
170
+ if (!source || !target) continue;
171
+
172
+ ctx.strokeStyle = RELATIONSHIP_COLORS[edge.relationship] || '#475569';
173
+ ctx.globalAlpha = 0.8;
174
+ ctx.beginPath();
175
+ ctx.moveTo(source.x, source.y);
176
+ ctx.lineTo(target.x, target.y);
177
+ ctx.stroke();
178
+
179
+ // Draw relationship label at midpoint
180
+ const midX = (source.x + target.x) / 2;
181
+ const midY = (source.y + target.y) / 2;
182
+ ctx.font = '10px system-ui';
183
+ ctx.fillStyle = RELATIONSHIP_COLORS[edge.relationship] || '#94a3b8';
184
+ ctx.globalAlpha = 1;
185
+ ctx.textAlign = 'center';
186
+ ctx.fillText(edge.relationship, midX, midY - 4);
187
+ }
188
+ } else {
189
+ // Show all edges very dimmed when nothing selected
190
+ ctx.lineWidth = 1;
191
+ ctx.globalAlpha = 0.15;
192
+ for (const edge of filteredEdges) {
193
+ const source = nodeMap.get(edge.source);
194
+ const target = nodeMap.get(edge.target);
195
+ if (!source || !target) continue;
196
+
197
+ ctx.strokeStyle = RELATIONSHIP_COLORS[edge.relationship] || '#475569';
198
+ ctx.beginPath();
199
+ ctx.moveTo(source.x, source.y);
200
+ ctx.lineTo(target.x, target.y);
201
+ ctx.stroke();
202
+ }
203
+ }
204
+ ctx.globalAlpha = 1;
205
+
206
+ // Draw nodes
207
+ for (const node of nodes) {
208
+ const isSelected = node.id === selectedNodeId;
209
+ const isConnected = connectedIds.has(node.id);
210
+ const isHovered = node.id === hoveredNodeId;
211
+ const isHighlighted = isSelected || isConnected || !selectedNodeId;
212
+
213
+ const baseRadius = 6 + node.salience * 6;
214
+ const radius = isSelected ? baseRadius + 4 : isHovered ? baseRadius + 2 : baseRadius;
215
+
216
+ // Determine opacity
217
+ const opacity = isHighlighted ? 1 : 0.2;
218
+
219
+ // Draw node
220
+ ctx.beginPath();
221
+ ctx.arc(node.x, node.y, radius, 0, Math.PI * 2);
222
+ ctx.fillStyle = CATEGORY_COLORS[node.category] || '#64748b';
223
+ ctx.globalAlpha = opacity;
224
+ ctx.fill();
225
+
226
+ // Selection/hover ring
227
+ if (isSelected || isHovered) {
228
+ ctx.strokeStyle = isSelected ? '#f472b6' : '#94a3b8';
229
+ ctx.lineWidth = isSelected ? 3 : 2;
230
+ ctx.globalAlpha = 1;
231
+ ctx.stroke();
232
+ }
233
+
234
+ // Draw label for highlighted nodes
235
+ if (isHighlighted && (isSelected || isConnected || isHovered || node.salience > 0.5)) {
236
+ ctx.globalAlpha = opacity;
237
+ const label = node.title.length > 20 ? node.title.slice(0, 20) + '...' : node.title;
238
+ ctx.font = isSelected ? 'bold 11px system-ui' : '10px system-ui';
239
+ ctx.textAlign = 'center';
240
+
241
+ // Background
242
+ const textWidth = ctx.measureText(label).width;
243
+ ctx.fillStyle = 'rgba(15, 23, 42, 0.9)';
244
+ ctx.fillRect(node.x - textWidth / 2 - 4, node.y + radius + 4, textWidth + 8, 16);
245
+
246
+ // Text
247
+ ctx.fillStyle = isSelected ? '#f472b6' : isConnected ? '#e2e8f0' : '#94a3b8';
248
+ ctx.fillText(label, node.x, node.y + radius + 16);
249
+ }
250
+ }
251
+
252
+ ctx.globalAlpha = 1;
253
+ }, [nodes, nodeMap, filteredEdges, selectedNodeId, hoveredNodeId, selectedConnections]);
254
+
255
+ // Mouse handling
256
+ useEffect(() => {
257
+ const canvas = canvasRef.current;
258
+ if (!canvas) return;
259
+
260
+ const getNodeAt = (x: number, y: number): Node | null => {
261
+ for (const node of nodes) {
262
+ const radius = 6 + node.salience * 6 + 4;
263
+ const dx = node.x - x;
264
+ const dy = node.y - y;
265
+ if (dx * dx + dy * dy < radius * radius) {
266
+ return node;
267
+ }
268
+ }
269
+ return null;
270
+ };
271
+
272
+ const handleClick = (e: MouseEvent) => {
273
+ const rect = canvas.getBoundingClientRect();
274
+ const x = e.clientX - rect.left;
275
+ const y = e.clientY - rect.top;
276
+ const node = getNodeAt(x, y);
277
+
278
+ if (node) {
279
+ setSelectedNodeId(node.id === selectedNodeId ? null : node.id);
280
+ } else {
281
+ setSelectedNodeId(null);
282
+ }
283
+ };
284
+
285
+ const handleMouseMove = (e: MouseEvent) => {
286
+ const rect = canvas.getBoundingClientRect();
287
+ const x = e.clientX - rect.left;
288
+ const y = e.clientY - rect.top;
289
+ const node = getNodeAt(x, y);
290
+ setHoveredNodeId(node?.id || null);
291
+ canvas.style.cursor = node ? 'pointer' : 'default';
292
+ };
293
+
294
+ canvas.addEventListener('click', handleClick);
295
+ canvas.addEventListener('mousemove', handleMouseMove);
296
+
297
+ return () => {
298
+ canvas.removeEventListener('click', handleClick);
299
+ canvas.removeEventListener('mousemove', handleMouseMove);
300
+ };
301
+ }, [nodes, selectedNodeId]);
302
+
303
+ return (
304
+ <div className="h-full flex flex-col">
305
+ {/* Controls */}
306
+ <div className="p-2 border-b border-slate-700 flex items-center gap-3">
307
+ <span className="text-xs text-slate-400">Filter:</span>
308
+ <button
309
+ onClick={() => setRelationshipFilter(null)}
310
+ className={`px-2 py-0.5 text-xs rounded ${
311
+ relationshipFilter === null
312
+ ? 'bg-slate-600 text-white'
313
+ : 'text-slate-400 hover:text-white'
314
+ }`}
315
+ >
316
+ All
317
+ </button>
318
+ {relationshipTypes.map((type) => (
319
+ <button
320
+ key={type}
321
+ onClick={() => setRelationshipFilter(type)}
322
+ className={`px-2 py-0.5 text-xs rounded ${
323
+ relationshipFilter === type ? 'text-white' : 'text-slate-400 hover:text-white'
324
+ }`}
325
+ style={{
326
+ backgroundColor: relationshipFilter === type ? RELATIONSHIP_COLORS[type] : 'transparent',
327
+ }}
328
+ >
329
+ {type}
330
+ </button>
331
+ ))}
332
+
333
+ <div className="flex-1" />
334
+
335
+ {/* Instructions */}
336
+ <span className="text-xs text-slate-500">
337
+ {selectedNodeId ? 'Click elsewhere to deselect' : 'Click a node to focus'}
338
+ </span>
339
+
340
+ {/* Legend */}
341
+ <div className="flex items-center gap-2 text-xs border-l border-slate-700 pl-3">
342
+ {Object.entries(RELATIONSHIP_COLORS).map(([type, color]) => (
343
+ <div key={type} className="flex items-center gap-1">
344
+ <div className="w-3 h-0.5" style={{ backgroundColor: color }} />
345
+ <span className="text-slate-500">{type}</span>
346
+ </div>
347
+ ))}
348
+ </div>
349
+ </div>
350
+
351
+ {/* Graph */}
352
+ <div className="flex-1 relative min-h-0">
353
+ <canvas ref={canvasRef} className="absolute inset-0 w-full h-full" />
354
+
355
+ {/* Selected Node Details */}
356
+ {selectedNode && (
357
+ <div className="absolute top-3 left-3 p-3 bg-slate-800/95 border border-slate-700 rounded-lg max-w-xs">
358
+ <div className="flex items-center gap-2 mb-2">
359
+ <div
360
+ className="w-3 h-3 rounded-full"
361
+ style={{ backgroundColor: CATEGORY_COLORS[selectedNode.category] }}
362
+ />
363
+ <span className="text-white font-medium text-sm">{selectedNode.title}</span>
364
+ </div>
365
+ <div className="text-xs text-slate-400 space-y-1">
366
+ <div>Category: {selectedNode.category}</div>
367
+ <div>Salience: {(selectedNode.salience * 100).toFixed(0)}%</div>
368
+ <div>Connections: {selectedConnections.connectedIds.size}</div>
369
+ </div>
370
+ {selectedConnections.connectedEdges.length > 0 && (
371
+ <div className="mt-2 pt-2 border-t border-slate-700">
372
+ <div className="text-xs text-slate-500 mb-1">Connected to:</div>
373
+ <div className="space-y-1 max-h-24 overflow-y-auto">
374
+ {selectedConnections.connectedEdges.map((edge, i) => {
375
+ const otherId = edge.source === selectedNodeId ? edge.target : edge.source;
376
+ const other = nodeMap.get(otherId);
377
+ return (
378
+ <div key={i} className="text-xs flex items-center gap-1">
379
+ <span
380
+ className="w-2 h-2 rounded-full shrink-0"
381
+ style={{ backgroundColor: RELATIONSHIP_COLORS[edge.relationship] }}
382
+ />
383
+ <span className="text-slate-400">{edge.relationship}:</span>
384
+ <span className="text-slate-300 truncate">{other?.title || 'Unknown'}</span>
385
+ </div>
386
+ );
387
+ })}
388
+ </div>
389
+ </div>
390
+ )}
391
+ </div>
392
+ )}
393
+
394
+ {/* Empty state */}
395
+ {nodes.length === 0 && (
396
+ <div className="absolute inset-0 flex items-center justify-center text-slate-500">
397
+ No relationships to display
398
+ </div>
399
+ )}
400
+ </div>
401
+ </div>
402
+ );
403
+ }