Skip to content

Commit 605119d

Browse files
committed
feat: Add an interactive SVG schema relationship graph component to visualize collection relationships on the query generator page.
1 parent c6ef8db commit 605119d

2 files changed

Lines changed: 249 additions & 25 deletions

File tree

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
2+
import React, { useMemo, useState } from 'react';
3+
import { Relationship, SchemaRelationshipsResponse } from '../types';
4+
5+
interface SchemaRelationshipGraphProps {
6+
relationships: SchemaRelationshipsResponse;
7+
selectedCollections: string[];
8+
}
9+
10+
interface Node {
11+
id: string;
12+
x: number;
13+
y: number;
14+
}
15+
16+
interface Edge {
17+
source: Node;
18+
target: Node;
19+
data: Relationship;
20+
}
21+
22+
const SchemaRelationshipGraph: React.FC<SchemaRelationshipGraphProps> = ({ relationships, selectedCollections }) => {
23+
const [hoveredNode, setHoveredNode] = useState<string | null>(null);
24+
const [hoveredEdge, setHoveredEdge] = useState<number | null>(null);
25+
26+
// Filter collections to only those selected (or involved in relationships between selected)
27+
// Actually, we should show all "selected" collections as nodes, even if isolated.
28+
const nodes: Node[] = useMemo(() => {
29+
const uniqueCols = Array.from(new Set(selectedCollections));
30+
const count = uniqueCols.length;
31+
const radius = 120; // Radius of the circle layout
32+
const centerX = 200;
33+
const centerY = 150; // Reduced height
34+
35+
if (count === 2) {
36+
return [
37+
{ id: uniqueCols[0], x: 80, y: centerY },
38+
{ id: uniqueCols[1], x: 320, y: centerY }
39+
];
40+
}
41+
42+
return uniqueCols.map((col, i) => {
43+
const angle = (i / count) * 2 * Math.PI - Math.PI / 2; // Start from top
44+
return {
45+
id: col,
46+
x: centerX + radius * Math.cos(angle),
47+
y: centerY + radius * Math.sin(angle),
48+
};
49+
});
50+
}, [selectedCollections]);
51+
52+
const edges: Edge[] = useMemo(() => {
53+
return relationships.relationships
54+
.filter(rel => selectedCollections.includes(rel.source_collection) && selectedCollections.includes(rel.target_collection))
55+
.map(rel => {
56+
const sourceNode = nodes.find(n => n.id === rel.source_collection);
57+
const targetNode = nodes.find(n => n.id === rel.target_collection);
58+
if (!sourceNode || !targetNode) return null;
59+
return { source: sourceNode, target: targetNode, data: rel };
60+
})
61+
.filter((e): e is Edge => e !== null);
62+
}, [relationships, nodes, selectedCollections]);
63+
64+
// Helper to calculate bezier curve control point
65+
const getPath = (source: Node, target: Node, index: number) => {
66+
const dx = target.x - source.x;
67+
const dy = target.y - source.y;
68+
const dist = Math.sqrt(dx * dx + dy * dy);
69+
70+
// Curvature logic: Straight line if direct, curved if multiple or loops
71+
let cx = (source.x + target.x) / 2;
72+
let cy = (source.y + target.y) / 2;
73+
74+
// Add offset for multiple edges or just for style
75+
// If it's a bidirectional pair, we need curvature.
76+
// Simple logic: curve upwards/downwards based on direction or index
77+
78+
// A simple consistent curve offset
79+
// const offset = 40; // This variable was not used, removed.
80+
// Calculate perpendicular vector
81+
const px = -dy / dist;
82+
const py = dx / dist;
83+
84+
// Small randomish offset based on data string hash or index to allow separate lines for multiple rels
85+
const curveAmount = 30 + (index * 20);
86+
87+
cx += px * curveAmount;
88+
cy += py * curveAmount;
89+
90+
// Calculate intersection point on the edge of the target circle (radius 30 + arrow size approx 10)
91+
// We want the arrow to point to the edge, efficiently.
92+
// Actually, Q curves are hard to chop exactly.
93+
// Easier approach: Use a marker-end that has refX properly set, OR calculate the point on the circle.
94+
95+
// Let's recalculate target/source points to be on the circumference.
96+
const radius = 30 + 5; // Node radius + buffer
97+
const angleSource = Math.atan2(cy - source.y, cx - source.x);
98+
const angleTarget = Math.atan2(cy - target.y, cx - target.x);
99+
100+
const sx = source.x + radius * Math.cos(angleSource);
101+
const sy = source.y + radius * Math.sin(angleSource);
102+
103+
const tx = target.x + radius * Math.cos(angleTarget);
104+
const ty = target.y + radius * Math.sin(angleTarget);
105+
106+
return `M ${sx} ${sy} Q ${cx} ${cy} ${tx} ${ty}`;
107+
};
108+
109+
return (
110+
<div className="w-full flex flex-col items-center bg-slate-50 dark:bg-slate-900/50 rounded-xl border border-slate-200 dark:border-slate-700 p-6 overflow-hidden relative">
111+
<div className="absolute top-2 right-2 flex gap-2">
112+
<div className="flex items-center gap-1 text-[10px] text-slate-400 dark:text-slate-500 bg-white dark:bg-slate-800 px-2 py-1 rounded-full border border-slate-200 dark:border-slate-700 shadow-sm">
113+
<span className="w-2 h-2 rounded-full bg-blue-500 animate-pulse"></span>
114+
AI Inferred Connection
115+
</div>
116+
</div>
117+
118+
<svg width="400" height="300" viewBox="0 0 400 300" className="w-full max-w-[500px] h-auto pointer-events-auto">
119+
<defs>
120+
<marker id="arrowhead" markerWidth="10" markerHeight="7" refX="22" refY="3.5" orient="auto">
121+
<polygon points="0 0, 10 3.5, 0 7" fill="#64748b" className="dark:fill-slate-400" />
122+
</marker>
123+
<marker id="arrowhead-hover" markerWidth="10" markerHeight="7" refX="22" refY="3.5" orient="auto">
124+
<polygon points="0 0, 10 3.5, 0 7" fill="#3b82f6" />
125+
</marker>
126+
<filter id="glow" x="-20%" y="-20%" width="140%" height="140%">
127+
<feGaussianBlur stdDeviation="2.5" result="coloredBlur" />
128+
<feMerge>
129+
<feMergeNode in="coloredBlur" />
130+
<feMergeNode in="SourceGraphic" />
131+
</feMerge>
132+
</filter>
133+
</defs>
134+
135+
{/* Edges */}
136+
{edges.map((edge, i) => {
137+
const isHovered = hoveredEdge === i;
138+
const pathData = getPath(edge.source, edge.target, i);
139+
140+
return (
141+
<g
142+
key={i}
143+
onMouseEnter={() => setHoveredEdge(i)}
144+
onMouseLeave={() => setHoveredEdge(null)}
145+
className="transition-opacity duration-300"
146+
style={{ opacity: (hoveredEdge !== null && !isHovered) ? 0.3 : 1 }}
147+
>
148+
{/* Invisible wideline for easier hovering */}
149+
<path d={pathData} stroke="transparent" strokeWidth="20" fill="none" className="cursor-pointer" />
150+
151+
{/* Visible line - No arrowheads */}
152+
<path
153+
d={pathData}
154+
stroke={isHovered ? "#3b82f6" : "#cbd5e1"}
155+
strokeWidth={isHovered ? 3 : 2}
156+
fill="none"
157+
className={`transition-colors duration-300 ${isHovered ? '' : 'dark:stroke-slate-600'}`}
158+
/>
159+
160+
{/* Animated particle flow only on hover */}
161+
{isHovered && (
162+
<circle r="3" fill="#3b82f6">
163+
<animateMotion dur="1.5s" repeatCount="indefinite" path={pathData} />
164+
</circle>
165+
)}
166+
</g>
167+
);
168+
})}
169+
170+
{/* Nodes */}
171+
{nodes.map(node => {
172+
const isHovered = hoveredNode === node.id;
173+
const isRelatedToHoveredEdge = hoveredEdge !== null && (edges[hoveredEdge!].source.id === node.id || edges[hoveredEdge!].target.id === node.id);
174+
175+
return (
176+
<g
177+
key={node.id}
178+
transform={`translate(${node.x},${node.y})`}
179+
onMouseEnter={() => setHoveredNode(node.id)}
180+
onMouseLeave={() => setHoveredNode(null)}
181+
className="cursor-pointer transition-all duration-300"
182+
style={{
183+
opacity: (hoveredEdge !== null && !isRelatedToHoveredEdge) ? 0.4 : 1
184+
}}
185+
>
186+
<circle
187+
r="30"
188+
fill={isHovered || isRelatedToHoveredEdge ? "#eff6ff" : "white"}
189+
stroke={isHovered || isRelatedToHoveredEdge ? "#3b82f6" : "#cbd5e1"}
190+
strokeWidth={isHovered ? 3 : 2}
191+
className={`transition-colors duration-300 dark:bg-slate-800 dark:fill-slate-800 ${isHovered ? '' : 'dark:stroke-slate-600'}`}
192+
filter={isHovered ? "url(#glow)" : ""}
193+
/>
194+
{/* Full Name */}
195+
<text
196+
y="5"
197+
textAnchor="middle"
198+
alignmentBaseline="middle"
199+
className={`text-[10px] font-bold pointer-events-none custom-text-shadow ${isHovered ? 'fill-blue-600' : 'fill-slate-600 dark:fill-slate-300'}`}
200+
style={{ textShadow: '0 1px 2px rgba(255,255,255,0.8)' }}
201+
>
202+
{node.id}
203+
</text>
204+
</g>
205+
);
206+
})}
207+
</svg>
208+
209+
{/* Detail Card Overlay - Appears when hovering an edge */}
210+
<div className={`absolute bottom-4 left-0 right-0 mx-auto w-[90%] bg-white/90 dark:bg-slate-800/90 backdrop-blur-sm border border-slate-200 dark:border-slate-600 shadow-lg rounded-lg p-3 transition-transform duration-300 ${hoveredEdge !== null ? 'translate-y-0 opacity-100' : 'translate-y-10 opacity-0 pointer-events-none'}`}>
211+
{hoveredEdge !== null && (
212+
<div className="flex flex-col items-center text-center">
213+
<div className="flex items-center gap-2 mb-1">
214+
<span className="font-mono bg-slate-100 dark:bg-slate-700 px-1.5 py-0.5 rounded text-xs text-slate-700 dark:text-slate-200 font-bold">
215+
{edges[hoveredEdge].data.source_collection}.{edges[hoveredEdge].data.source_field}
216+
</span>
217+
<span className="text-slate-400"></span>
218+
<span className="font-mono bg-slate-100 dark:bg-slate-700 px-1.5 py-0.5 rounded text-xs text-slate-700 dark:text-slate-200 font-bold">
219+
{edges[hoveredEdge].data.target_collection}.{edges[hoveredEdge].data.target_field}
220+
</span>
221+
</div>
222+
<p className="text-xs text-slate-600 dark:text-slate-400">
223+
{edges[hoveredEdge].data.description}
224+
</p>
225+
</div>
226+
)}
227+
</div>
228+
229+
{/* Empty State Overlay */}
230+
{edges.length === 0 && (
231+
<div className="absolute inset-0 flex items-center justify-center pointer-events-none">
232+
<div className="bg-white/80 dark:bg-slate-900/80 backdrop-blur p-4 rounded-lg text-center">
233+
<p className="text-slate-500 text-sm">No connections found</p>
234+
</div>
235+
</div>
236+
)}
237+
238+
</div>
239+
);
240+
};
241+
242+
export default SchemaRelationshipGraph;

frontend/pages/QueryGeneratorPage.tsx

Lines changed: 7 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { generateMongoQuery, debugMongoQuery, analyzeQueryResult, inferSchemaRel
44
import { getAzureCosmosAccounts, getDatabasesForAccount, runMongoQuery, getCollectionInfo, clearSystemCache } from '../services/dbService';
55
import { getSavedQueries, saveQuery, updateSavedQuery, deleteSavedQuery } from '../services/userDataService';
66
import { generateIpynbContent, downloadFile } from '../services/notebookService';
7-
import { QueryResultData, DbInfo, CollectionInfo, CosmosDBAccount, SelectedResource, DebuggingResult, AnalysisResult, NotebookStep, SavedQuery, SchemaRelationshipsResponse, Relationship } from '../types';
7+
import { QueryResultData, DbInfo, CollectionInfo, CosmosDBAccount, SelectedResource, DebuggingResult, AnalysisResult, NotebookStep, SavedQuery, SchemaRelationshipsResponse } from '../types';
88
import { mockECommerceDbInfo, mockCollectionInfoMap, mockFindUsersQuery, mockUserFindResult, mockSavedQueries } from '../services/mockData';
99
import { getAuthErrorMessage, isAuthenticationExpiredError } from '../utils/authErrorHandler';
1010
import QueryDisplay from '../components/QueryDisplay';
@@ -13,6 +13,7 @@ import Loader from '../components/Loader';
1313
import Tutorial from '../components/Tutorial';
1414
import JsonDisplay from '../components/JsonDisplay';
1515
import CollectionActionPanel from '../components/CollectionActionPanel';
16+
import SchemaRelationshipGraph from '../components/SchemaRelationshipGraph';
1617
import SavedQueriesPanel from '../components/SavedQueriesPanel';
1718
import SaveQueryDialog from '../components/SaveQueryDialog';
1819
import ShareQueryDialog from '../components/ShareQueryDialog';
@@ -1259,30 +1260,11 @@ const QueryGeneratorPage: React.FC<QueryGeneratorPageProps> = ({ name, email, on
12591260
)}
12601261

12611262
{relationships && !isAnalyzingRelationships && (
1262-
<div className="grid gap-3 animate-fade-in">
1263-
{(() => {
1264-
// Strict filtering: Only show if both source and target are in the selection
1265-
const filteredRelationships = relationships.relationships.filter(
1266-
rel => selectedCollections.includes(rel.source_collection) && selectedCollections.includes(rel.target_collection)
1267-
);
1268-
1269-
if (filteredRelationships.length === 0) {
1270-
return <p className="text-sm text-slate-500 italic">No obvious relationships found between the selected collections.</p>;
1271-
}
1272-
1273-
return filteredRelationships.map((rel, idx) => (
1274-
<div key={idx} className="bg-white dark:bg-slate-800 p-3 rounded border border-slate-200 dark:border-slate-600 flex flex-col sm:flex-row sm:items-center justify-between gap-2 shadow-sm">
1275-
<div className="flex items-center gap-2 text-sm font-mono overflow-x-auto">
1276-
<span className="text-slate-700 dark:text-slate-200 font-bold bg-slate-100 dark:bg-slate-700/60 px-2 py-1 rounded border border-slate-200 dark:border-slate-600">{rel.source_collection}.{rel.source_field}</span>
1277-
<span className="text-slate-400"></span>
1278-
<span className="text-slate-700 dark:text-slate-200 font-bold bg-slate-100 dark:bg-slate-700/60 px-2 py-1 rounded border border-slate-200 dark:border-slate-600">{rel.target_collection}.{rel.target_field}</span>
1279-
</div>
1280-
<div className="flex flex-col sm:items-end">
1281-
<span className="text-xs text-slate-500 dark:text-slate-400">{rel.description}</span>
1282-
</div>
1283-
</div>
1284-
));
1285-
})()}
1263+
<div className="animate-fade-in w-full flex justify-center">
1264+
<SchemaRelationshipGraph
1265+
relationships={relationships}
1266+
selectedCollections={selectedCollections}
1267+
/>
12861268
</div>
12871269
)}
12881270
</div>

0 commit comments

Comments
 (0)