glam/frontend/src/hooks/useGraphData.ts
2025-12-03 17:38:46 +01:00

234 lines
6.6 KiB
TypeScript

/**
* React Hook for Graph Data Management
* Processes parsed RDF data for D3.js visualization
*/
import { useState, useCallback, useMemo } from 'react';
import {
extractNodeTypes,
extractPredicates,
getLinkCounts,
getNodeCounts,
} from '@/lib/rdf/graph-utils';
import type { GraphData, GraphNode, GraphLink, SemanticCategory } from '@/types/rdf';
import { getSemanticCategory } from '@/types/rdf';
export interface GraphFilters {
nodeTypes: Set<string>;
predicates: Set<string>;
searchTerm: string;
/** Hide literal nodes (strings, numbers, dates) from the graph */
hideLiterals: boolean;
/** Semantic categories to show (empty = show all) */
semanticCategories: Set<SemanticCategory>;
}
interface UseGraphDataReturn {
// Data
nodes: GraphNode[];
links: GraphLink[];
filteredNodes: GraphNode[];
filteredLinks: GraphLink[];
// Metadata
nodeTypes: string[];
predicates: string[];
linkCounts: Record<string, number>;
nodeCounts: Record<string, number>;
// Filters
filters: GraphFilters;
setFilters: (filters: Partial<GraphFilters>) => void;
resetFilters: () => void;
// Selection
selectedNode: GraphNode | null;
setSelectedNode: (node: GraphNode | null) => void;
// Data loading
loadGraphData: (data: GraphData) => void;
clearGraphData: () => void;
// Stats
stats: {
totalNodes: number;
totalLinks: number;
filteredNodeCount: number;
filteredLinkCount: number;
};
}
const DEFAULT_FILTERS: GraphFilters = {
nodeTypes: new Set(),
predicates: new Set(),
searchTerm: '',
hideLiterals: true, // Default to hiding literals for cleaner graphs
semanticCategories: new Set(), // Empty = show all categories
};
export function useGraphData(): UseGraphDataReturn {
const [graphData, setGraphData] = useState<GraphData | null>(null);
const [filters, setFiltersState] = useState<GraphFilters>(DEFAULT_FILTERS);
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null);
// Extract metadata from graph data
const nodeTypes = useMemo(
() => (graphData ? extractNodeTypes(graphData) : []),
[graphData]
);
const predicates = useMemo(
() => (graphData ? extractPredicates(graphData) : []),
[graphData]
);
const linkCounts = useMemo(
() => (graphData ? getLinkCounts(graphData) : {}),
[graphData]
);
const nodeCounts = useMemo(
() => (graphData ? getNodeCounts(graphData) : {}),
[graphData]
);
// Get raw nodes and links
const nodes = graphData?.nodes || [];
const links = graphData?.links || [];
// Apply filters to nodes
const filteredNodes = useMemo(() => {
if (!graphData) return [];
let result = nodes;
// Filter by semantic categories (if any selected)
if (filters.semanticCategories.size > 0) {
result = result.filter((node) => {
const category = getSemanticCategory(node.type);
return filters.semanticCategories.has(category);
});
}
// Filter out literals if hideLiterals is enabled
if (filters.hideLiterals) {
result = result.filter((node) => {
const category = getSemanticCategory(node.type);
return category !== 'literals';
});
}
// Filter by node type
if (filters.nodeTypes.size > 0) {
result = result.filter((node) => filters.nodeTypes.has(node.type));
}
// Filter by search term
if (filters.searchTerm) {
const term = filters.searchTerm.toLowerCase();
result = result.filter(
(node) =>
node.id.toLowerCase().includes(term) ||
node.label?.toLowerCase().includes(term)
);
}
return result;
}, [graphData, nodes, filters]);
// Apply filters to links and expand to include connected nodes
const { filteredLinks, expandedNodes } = useMemo(() => {
if (!graphData) return { filteredLinks: [], expandedNodes: [] as GraphNode[] };
// Start with the filtered nodes based on type/search
const primaryNodeIds = new Set(filteredNodes.map((n) => n.id));
// Find links where at least ONE end is a primary node
// This ensures we see connections FROM the filtered nodes
let relevantLinks = links.filter((link) => {
const sourceId = typeof link.source === 'string' ? link.source : link.source.id;
const targetId = typeof link.target === 'string' ? link.target : link.target.id;
return primaryNodeIds.has(sourceId) || primaryNodeIds.has(targetId);
});
// Filter by predicate if specified
if (filters.predicates.size > 0) {
relevantLinks = relevantLinks.filter((link) => filters.predicates.has(link.predicate));
}
// Collect all nodes involved in the links
const expandedNodeIds = new Set(primaryNodeIds);
for (const link of relevantLinks) {
const sourceId = typeof link.source === 'string' ? link.source : link.source.id;
const targetId = typeof link.target === 'string' ? link.target : link.target.id;
expandedNodeIds.add(sourceId);
expandedNodeIds.add(targetId);
}
// Return expanded set of nodes (primary + connected)
const expanded = nodes.filter((n) => expandedNodeIds.has(n.id));
return { filteredLinks: relevantLinks, expandedNodes: expanded };
}, [graphData, nodes, links, filteredNodes, filters.predicates]);
// Use expandedNodes instead of filteredNodes for display
const displayNodes = expandedNodes.length > 0 ? expandedNodes : filteredNodes;
// Load graph data
const loadGraphData = useCallback((data: GraphData) => {
setGraphData(data);
setSelectedNode(null);
// Reset filters when loading new data
setFiltersState(DEFAULT_FILTERS);
}, []);
// Clear graph data
const clearGraphData = useCallback(() => {
setGraphData(null);
setSelectedNode(null);
setFiltersState(DEFAULT_FILTERS);
}, []);
// Update filters
const setFilters = useCallback((newFilters: Partial<GraphFilters>) => {
setFiltersState((prev) => ({
...prev,
...newFilters,
}));
}, []);
// Reset filters
const resetFilters = useCallback(() => {
setFiltersState(DEFAULT_FILTERS);
}, []);
// Stats
const stats = useMemo(
() => ({
totalNodes: nodes.length,
totalLinks: links.length,
filteredNodeCount: displayNodes.length,
filteredLinkCount: filteredLinks.length,
}),
[nodes.length, links.length, displayNodes.length, filteredLinks.length]
);
return {
nodes,
links,
filteredNodes: displayNodes,
filteredLinks,
nodeTypes,
predicates,
linkCounts,
nodeCounts,
filters,
setFilters,
resetFilters,
selectedNode,
setSelectedNode,
loadGraphData,
clearGraphData,
stats,
};
}