Skip to content

Commit 79d9c55

Browse files
committed
feat(catalog-graph): clickable node detail, filters, floating edges, search
Major upgrade to Catalogs > Relations > Graph view: Node interactions: - Clicking a node opens a floating detail card (ReactFlow NodeToolbar) with lazy-fetched rich metadata (description/code/aliases), relation degree, and action buttons: Open in catalog, Open in domain - Right-click context menu with the same actions - Hover preview (info bar + CSS-driven highlight) - Drag no longer triggers the popup (click/drag disambiguation) - Close button + pane click deselect without moving the viewport Visual improvements: - Custom node type with icon, left-aligned text, accurate colors - Floating edges that auto-compute optimal border connection points (no unnecessary curves, re-computes on drag) - Collapsible color legend (node kinds + edge relations) - Search-to-find box (dims non-matches, Enter centers on match) Filtering: - Relation-type filter chips (grouped, icon-rich, client-side edge filter) - Enhanced node-type chips with icons + live counts - Concept-kind sub-chips retained Architecture: - Threaded catalog type through ConceptGraphNode (fixes cross-type nav bug) - Shared domainRouteForType helper (extracted from CatalogWorkspace) - Routable ?view=graph URL param for deep-linkable list/graph toggle - Single-click is local-only (instant); double-click navigates Tests: 27 new tests across 5 test files; 122 total passing; build clean.
1 parent a18d5e2 commit 79d9c55

15 files changed

Lines changed: 1722 additions & 60 deletions

frontend/src/components/catalog/CatalogOntologyGraph.tsx

Lines changed: 79 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -21,29 +21,34 @@ import {
2121
type ConceptGraphNode,
2222
type ConceptGraphEdgeData,
2323
} from '../ui/ConceptGraphView';
24+
import { GraphNodeDetail } from './GraphNodeDetail';
25+
import { GraphNodeContextMenu } from './GraphNodeContextMenu';
26+
import { GraphRelationFilter } from './GraphRelationFilter';
27+
import { DynamicIcon } from '../ui/DynamicIcon';
2428
import { getCatalogGraph } from '../../services/catalogService';
2529
import {
2630
CONCEPT_KIND_LABELS,
2731
KIND_COLORS,
2832
CATALOG_TYPE_COLORS,
2933
CATALOG_TYPE_LABELS,
34+
CATALOG_TYPE_ICONS,
3035
type ConceptKind,
3136
} from '../../types/concept';
3237

3338
const ALL_CATALOG_TYPES = Object.keys(CATALOG_TYPE_LABELS);
3439

3540
interface CatalogOntologyGraphProps {
36-
/** Called when a node is double-clicked (focus). */
37-
onFocusNode?: (conceptId: string) => void;
38-
/** Called when a node is single-clicked (select). */
39-
onSelectNode?: (conceptId: string) => void;
41+
/** Called when a node is double-clicked (focus). Carries the node's catalog
42+
* type so the workspace can navigate with the correct ``?type=`` (the
43+
* ontology graph is cross-catalog — a clicked node may belong to a
44+
* different type than the one currently browsed). */
45+
onFocusNode?: (node: { id: string; type?: string | null }) => void;
4046
/** Bump to force a refetch without remounting. */
4147
refreshKey?: number;
4248
}
4349

4450
export const CatalogOntologyGraph: React.FC<CatalogOntologyGraphProps> = ({
4551
onFocusNode,
46-
onSelectNode,
4752
refreshKey = 0,
4853
}) => {
4954
const { t } = useTranslation();
@@ -61,6 +66,7 @@ export const CatalogOntologyGraph: React.FC<CatalogOntologyGraphProps> = ({
6166
const [selectedNode, setSelectedNode] = useState<string | undefined>();
6267
const [depth, setDepth] = useState(0);
6368
const [hiddenKinds, setHiddenKinds] = useState<string[]>([]);
69+
const [hiddenRelations, setHiddenRelations] = useState<Set<string>>(new Set());
6470

6571
const load = useCallback(async () => {
6672
setLoading(true);
@@ -89,6 +95,8 @@ export const CatalogOntologyGraph: React.FC<CatalogOntologyGraphProps> = ({
8995
|| KIND_COLORS[kindOrType as ConceptKind]
9096
|| CATALOG_TYPE_COLORS[kindOrType]
9197
|| '#6b7280',
98+
type: n.type,
99+
icon: n.icon,
92100
};
93101
}),
94102
);
@@ -113,13 +121,18 @@ export const CatalogOntologyGraph: React.FC<CatalogOntologyGraphProps> = ({
113121
load();
114122
}, [load, refreshKey]);
115123

116-
// Client-side BFS depth filter from the selected node.
124+
// Client-side BFS depth filter from the selected node, then relation filter.
117125
const displayedGraph = useMemo(() => {
126+
// First, apply relation-type filter to the raw edges.
127+
const relEdges = hiddenRelations.size > 0
128+
? rawEdges.filter((e) => !hiddenRelations.has(e.relation))
129+
: rawEdges;
130+
118131
if (depth === 0 || !selectedNode) {
119-
return { nodes: rawNodes, edges: rawEdges };
132+
return { nodes: rawNodes, edges: relEdges };
120133
}
121134
const adj = new Map<string, string[]>();
122-
for (const e of rawEdges) {
135+
for (const e of relEdges) {
123136
if (!adj.has(e.source)) adj.set(e.source, []);
124137
if (!adj.has(e.target)) adj.set(e.target, []);
125138
adj.get(e.source)!.push(e.target);
@@ -141,11 +154,20 @@ export const CatalogOntologyGraph: React.FC<CatalogOntologyGraphProps> = ({
141154
}
142155
return {
143156
nodes: rawNodes.filter((n) => visited.has(n.id)),
144-
edges: rawEdges.filter(
157+
edges: relEdges.filter(
145158
(e) => visited.has(e.source) && visited.has(e.target),
146159
),
147160
};
148-
}, [rawNodes, rawEdges, depth, selectedNode]);
161+
}, [rawNodes, rawEdges, depth, selectedNode, hiddenRelations]);
162+
163+
const toggleRelation = (relation: string) => {
164+
setHiddenRelations((prev) => {
165+
const next = new Set(prev);
166+
if (next.has(relation)) next.delete(relation);
167+
else next.add(relation);
168+
return next;
169+
});
170+
};
149171

150172
const toggleType = (type: string) => {
151173
setActiveTypes((prev) => {
@@ -186,18 +208,31 @@ export const CatalogOntologyGraph: React.FC<CatalogOntologyGraphProps> = ({
186208
<div className="flex flex-wrap gap-1">
187209
{ALL_CATALOG_TYPES.map((type) => {
188210
const active = activeTypes.size === 0 || activeTypes.has(type);
211+
const typeCount = displayedGraph.nodes.filter(
212+
(n) => n.type === type,
213+
).length;
189214
return (
190215
<button
191216
key={type}
192217
onClick={() => toggleType(type)}
193-
className={`px-2 py-0.5 text-[11px] font-bold rounded-full border transition-all ${
218+
title={`${CATALOG_TYPE_LABELS[type]} (${typeCount})`}
219+
className={`flex items-center gap-1 px-2 py-0.5 text-[11px] font-bold rounded-full border transition-all ${
194220
active
195221
? 'text-white border-transparent'
196222
: 'border-gray-200 dark:border-gray-600 text-gray-400 hover:bg-gray-50 dark:hover:bg-gray-700 opacity-40'
197223
}`}
198224
style={active ? { backgroundColor: CATALOG_TYPE_COLORS[type] || '#6b7280' } : undefined}
199225
>
226+
<DynamicIcon
227+
icon={CATALOG_TYPE_ICONS[type] ?? 'Circle'}
228+
className="w-2.5 h-2.5"
229+
/>
200230
{CATALOG_TYPE_LABELS[type]}
231+
{typeCount > 0 && (
232+
<span className="ml-0.5 px-1 rounded-full text-[9px] bg-black/20">
233+
{typeCount}
234+
</span>
235+
)}
201236
</button>
202237
);
203238
})}
@@ -278,6 +313,16 @@ export const CatalogOntologyGraph: React.FC<CatalogOntologyGraphProps> = ({
278313
</div>
279314
</div>
280315
)}
316+
317+
{/* Row 3: relation-type (edge) filter chips.
318+
Uses rawEdges (NOT displayedGraph.edges) so the chips persist after
319+
a relation is hidden — the filter must show what *can* be toggled,
320+
not just what's currently visible. */}
321+
<GraphRelationFilter
322+
edges={rawEdges}
323+
hidden={hiddenRelations}
324+
onToggle={toggleRelation}
325+
/>
281326
</div>
282327

283328
{/* Graph canvas */}
@@ -297,13 +342,34 @@ export const CatalogOntologyGraph: React.FC<CatalogOntologyGraphProps> = ({
297342
selectedNodeId={selectedNode}
298343
hiddenKinds={hiddenKinds}
299344
onSelectNode={(id) => {
345+
// Single-click is purely local (show detail card). Navigation
346+
// (URL change + catalog reload) happens on double-click below —
347+
// doing both on single-click caused a 3s reload delay.
300348
setSelectedNode(id);
301-
onSelectNode?.(id);
302349
}}
303350
onFocusNode={(id) => {
304351
setSelectedNode(id);
305-
onFocusNode?.(id);
352+
onFocusNode?.({ id, type: rawNodes.find((n) => n.id === id)?.type });
306353
}}
354+
onClearSelection={() => setSelectedNode(undefined)}
355+
renderNodeDetail={({ node, degree, onClose, onFocus }) => (
356+
<GraphNodeDetail
357+
node={node}
358+
degree={degree}
359+
onClose={onClose}
360+
onFocus={onFocus}
361+
/>
362+
)}
363+
renderContextMenu={({ x, y, node, onClose, onFocus }) => (
364+
<GraphNodeContextMenu
365+
x={x}
366+
y={y}
367+
type={node.type ?? ''}
368+
id={node.id}
369+
onClose={onClose}
370+
onFocus={onFocus}
371+
/>
372+
)}
307373
/>
308374
)}
309375
</div>

frontend/src/components/catalog/CatalogRelationsGraph.tsx

Lines changed: 57 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,9 @@ import {
1717
} from '../ui/ConceptGraphView';
1818
import { LoadingState } from '../ui/LoadingState';
1919
import { CatalogRelationsCards } from './CatalogRelationsCards';
20+
import { GraphNodeDetail } from './GraphNodeDetail';
21+
import { GraphNodeContextMenu } from './GraphNodeContextMenu';
22+
import { GraphRelationFilter } from './GraphRelationFilter';
2023
import { getCatalogRelations } from '../../services/catalogService';
2124
import type { CatalogRelationResponse } from '../../types/catalog';
2225

@@ -42,6 +45,7 @@ export const CatalogRelationsGraph: React.FC<CatalogRelationsGraphProps> = ({
4245
const [loading, setLoading] = useState(false);
4346
const [error, setError] = useState<string | null>(null);
4447
const [selectedNodeId, setSelectedNodeId] = useState<string | undefined>();
48+
const [hiddenRelations, setHiddenRelations] = useState<Set<string>>(new Set());
4549

4650
const load = React.useCallback(async () => {
4751
setLoading(true);
@@ -69,6 +73,8 @@ export const CatalogRelationsGraph: React.FC<CatalogRelationsGraphProps> = ({
6973
primary_kind: n.kind || n.type,
7074
kinds: [n.kind || n.type],
7175
color: n.color || RELATION_COLORS[n.type] || '#6b7280',
76+
type: n.type,
77+
icon: n.icon,
7278
}));
7379
const edges: ConceptGraphEdgeData[] = (data.edges || []).map((e) => ({
7480
id: e.id,
@@ -79,6 +85,24 @@ export const CatalogRelationsGraph: React.FC<CatalogRelationsGraphProps> = ({
7985
return { nodes, edges };
8086
}, [data]);
8187

88+
// Client-side relation-type filter.
89+
const displayedEdges = useMemo(
90+
() =>
91+
hiddenRelations.size > 0
92+
? edges.filter((e) => !hiddenRelations.has(e.relation))
93+
: edges,
94+
[edges, hiddenRelations],
95+
);
96+
97+
const toggleRelation = (relation: string) => {
98+
setHiddenRelations((prev) => {
99+
const next = new Set(prev);
100+
if (next.has(relation)) next.delete(relation);
101+
else next.add(relation);
102+
return next;
103+
});
104+
};
105+
82106
if (loading) return <LoadingState variant="section" message="Loading relations…" />;
83107
if (error) return <p className="text-sm text-red-500">{error}</p>;
84108
if (!data || nodes.length <= 1)
@@ -104,7 +128,7 @@ export const CatalogRelationsGraph: React.FC<CatalogRelationsGraphProps> = ({
104128
<option value={3}>3 hops</option>
105129
</select>
106130
<span className="text-xs text-gray-400">
107-
{nodes.length} nodes · {edges.length} edges
131+
{nodes.length} nodes · {displayedEdges.length} edges
108132
</span>
109133
{/* Graph / Cards sub-tab toggle */}
110134
<div className="ml-auto flex items-center rounded-lg border border-gray-300 dark:border-gray-600 overflow-hidden">
@@ -141,14 +165,45 @@ export const CatalogRelationsGraph: React.FC<CatalogRelationsGraphProps> = ({
141165
/>
142166
</div>
143167
) : (
168+
<>
169+
{/* Relation-type filter chips.
170+
Uses edges (NOT displayedEdges) so chips persist after hiding. */}
171+
<div className="shrink-0">
172+
<GraphRelationFilter
173+
edges={edges}
174+
hidden={hiddenRelations}
175+
onToggle={toggleRelation}
176+
/>
177+
</div>
144178
<div className="flex-1 relative min-h-[400px] rounded-lg border border-gray-200 dark:border-gray-700 overflow-hidden">
145179
<ConceptGraphView
146180
nodes={nodes}
147-
edges={edges}
181+
edges={displayedEdges}
148182
selectedNodeId={selectedNodeId}
149183
onSelectNode={setSelectedNodeId}
184+
onFocusNode={setSelectedNodeId}
185+
onClearSelection={() => setSelectedNodeId(undefined)}
186+
renderNodeDetail={({ node, degree, onClose, onFocus }) => (
187+
<GraphNodeDetail
188+
node={node}
189+
degree={degree}
190+
onClose={onClose}
191+
onFocus={onFocus}
192+
/>
193+
)}
194+
renderContextMenu={({ x, y, node, onClose, onFocus }) => (
195+
<GraphNodeContextMenu
196+
x={x}
197+
y={y}
198+
type={node.type ?? ''}
199+
id={node.id}
200+
onClose={onClose}
201+
onFocus={onFocus}
202+
/>
203+
)}
150204
/>
151205
</div>
206+
</>
152207
)}
153208
</div>
154209
);

0 commit comments

Comments
 (0)