From 0d501afdd7d86ef885980c32d33aabc68ac2cac3 Mon Sep 17 00:00:00 2001 From: Arun Sharma Date: Wed, 27 May 2026 11:37:29 -0700 Subject: [PATCH 1/4] Improve hierarchical cluster exploration --- src-tauri/src/lib.rs | 171 +++++++++++++++++++ src/App.css | 140 ++++++++++++++-- src/App.tsx | 385 ++++++++++++++++++++++++++++++++----------- 3 files changed, 579 insertions(+), 117 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 16df75c..601400a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -103,6 +103,9 @@ struct GraphData { const SEED_NODE_COUNT: usize = 8; const EXPAND_BATCH_SIZE: usize = 8; const EDGE_SCAN_LIMIT: usize = 10_000; +const NODE_SEARCH_SCAN_LIMIT: usize = 10_000; +const SEARCH_RESULT_LIMIT: usize = 30; +const SEARCH_NEIGHBOR_LIMIT: usize = 24; #[cfg(feature = "icebug-analytics")] const CLUSTER_LEVEL_LIMIT: usize = 3; const EXPANDER_PREFIX: &str = "__expand__:"; @@ -217,6 +220,50 @@ fn value_to_string(val: &Value) -> String { } } +fn graph_node_from_value(val: &Value) -> Option { + let Value::Node(node_val) = val else { + return None; + }; + + let props = node_val.get_properties(); + let name = props + .iter() + .find(|(key, _)| key == "name") + .or_else(|| props.iter().find(|(key, _)| key == "id")) + .or_else(|| props.iter().find(|(key, _)| key == "title")) + .map(|(_, prop_val)| value_to_string(prop_val)) + .unwrap_or_else(|| "Node".to_string()); + + Some(GraphNode { + id: id_to_string(node_val.get_node_id()), + name, + label: node_val.get_label_name().clone(), + table_id: Some(node_val.get_node_id().table_id), + rowid: Some(node_val.get_node_id().offset), + community: None, + expansion_kind: None, + expand_node_id: None, + offset: None, + hidden_count: None, + }) +} + +fn node_value_matches_query(val: &Value, query: &str) -> bool { + let Value::Node(node_val) = val else { + return false; + }; + + let node_id = id_to_string(node_val.get_node_id()); + let label = node_val.get_label_name(); + let props = node_val.get_properties(); + node_id.to_lowercase().contains(query) + || label.to_lowercase().contains(query) + || props.iter().any(|(key, prop_val)| { + key.to_lowercase().contains(query) + || value_to_string(prop_val).to_lowercase().contains(query) + }) +} + fn id_to_string(id: &lbug::InternalID) -> String { format!("{}:{}", id.table_id, id.offset) } @@ -1018,6 +1065,128 @@ fn get_graph(state: State, id: usize) -> Result { collect_edge_graph(&conn, EDGE_SCAN_LIMIT).map(seed_graph_from_full) } +#[tauri::command] +fn search_nodes( + state: State, + id: usize, + query: String, +) -> Result, String> { + let query_text = query.trim(); + if query_text.is_empty() { + return Ok(Vec::new()); + } + let query = query_text.to_lowercase(); + + let databases = get_all_databases(&state); + let db_info = databases.get(id).ok_or("Database not found")?; + + let db = Database::new(&db_info.path, SystemConfig::default()) + .map_err(|e| format!("Failed to open database: {}", e))?; + let conn = Connection::new(&db).map_err(|e| format!("Failed to create connection: {}", e))?; + + let mut result = conn + .query(&format!( + "MATCH (n) RETURN n LIMIT {NODE_SEARCH_SCAN_LIMIT}" + )) + .map_err(|e| format!("Node search query failed: {}", e))?; + + let mut matches = Vec::new(); + for row in &mut result { + for val in row.iter() { + if node_value_matches_query(val, &query) { + if let Some(node) = graph_node_from_value(val) { + matches.push(node); + } + } + } + } + + matches.sort_by(|a, b| { + let a_exact = + a.name.eq_ignore_ascii_case(query_text) || a.id.eq_ignore_ascii_case(query_text); + let b_exact = + b.name.eq_ignore_ascii_case(query_text) || b.id.eq_ignore_ascii_case(query_text); + b_exact + .cmp(&a_exact) + .then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase())) + .then_with(|| a.id.cmp(&b.id)) + }); + matches.truncate(SEARCH_RESULT_LIMIT); + Ok(matches) +} + +#[tauri::command] +fn get_node_neighborhood( + state: State, + id: usize, + node_id: String, +) -> Result { + let focus_node_id = node_id; + let databases = get_all_databases(&state); + let db_info = databases.get(id).ok_or("Database not found")?; + + let db = Database::new(&db_info.path, SystemConfig::default()) + .map_err(|e| format!("Failed to open database: {}", e))?; + let conn = Connection::new(&db).map_err(|e| format!("Failed to create connection: {}", e))?; + let full_graph = collect_edge_graph(&conn, EDGE_SCAN_LIMIT)?; + + let mut degrees: HashMap = HashMap::new(); + for link in &full_graph.links { + *degrees.entry(link.source.clone()).or_insert(0) += 1; + *degrees.entry(link.target.clone()).or_insert(0) += 1; + } + + let mut neighbor_ids: Vec = full_graph + .links + .iter() + .filter_map(|link| { + if link.source == focus_node_id { + Some(link.target.clone()) + } else if link.target == focus_node_id { + Some(link.source.clone()) + } else { + None + } + }) + .collect(); + neighbor_ids.sort(); + neighbor_ids.dedup(); + neighbor_ids.sort_by_key(|id| std::cmp::Reverse(*degrees.get(id).unwrap_or(&0))); + + let mut visible_ids: HashSet = neighbor_ids + .into_iter() + .take(SEARCH_NEIGHBOR_LIMIT) + .collect(); + visible_ids.insert(focus_node_id.clone()); + + let full_node_by_id: HashMap = full_graph + .nodes + .iter() + .map(|node| (node.id.clone(), node.clone())) + .collect(); + let mut nodes: Vec = visible_ids + .iter() + .filter_map(|id| full_node_by_id.get(id).cloned()) + .collect(); + nodes.sort_by_key(|node| { + ( + node.id != focus_node_id, + std::cmp::Reverse(*degrees.get(&node.id).unwrap_or(&0)), + node.name.clone(), + ) + }); + + let mut links: Vec = full_graph + .links + .iter() + .filter(|link| visible_ids.contains(&link.source) && visible_ids.contains(&link.target)) + .cloned() + .collect(); + + add_expanders(&full_graph, &visible_ids, &mut nodes, &mut links); + Ok(graph_data(nodes, links)) +} + #[tauri::command] fn expand_node( state: State, @@ -1151,6 +1320,8 @@ pub fn run() { add_database, get_directories, get_graph, + search_nodes, + get_node_neighborhood, expand_node, execute_query, ]) diff --git a/src/App.css b/src/App.css index cb451cd..f7d45c9 100644 --- a/src/App.css +++ b/src/App.css @@ -81,6 +81,99 @@ body { color: white; } +.node-search { + margin-top: 20px; + padding-top: 16px; + border-top: 1px solid var(--border-color); +} + +.panel-title { + margin-bottom: 8px; + color: var(--text-secondary); + font-size: 12px; + font-weight: 700; + text-transform: uppercase; +} + +.node-search-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 6px; +} + +.node-search-row input { + min-width: 0; + padding: 8px 10px; + background-color: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 4px; + font-size: 13px; +} + +.node-search-row input:focus { + outline: none; + border-color: var(--accent-color); +} + +.node-search-row button, +.search-result { + border: 1px solid var(--border-color); + border-radius: 4px; + cursor: pointer; +} + +.node-search-row button { + padding: 0 10px; + background-color: var(--accent-color); + color: white; +} + +.node-search-row button:disabled { + cursor: not-allowed; + opacity: 0.5; +} + +.search-error { + margin-top: 8px; + color: #f44; + font-size: 13px; +} + +.search-results { + display: flex; + flex-direction: column; + gap: 6px; + margin-top: 10px; +} + +.search-result { + display: flex; + min-width: 0; + flex-direction: column; + gap: 2px; + padding: 8px 10px; + background-color: transparent; + color: var(--text-primary); + text-align: left; +} + +.search-result:hover { + background-color: var(--bg-tertiary); +} + +.search-result span, +.search-result small { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.search-result small { + color: var(--text-secondary); + font-size: 11px; +} + .toggle-btn { position: absolute; left: 16px; @@ -132,36 +225,30 @@ body { display: inline-flex; align-items: center; height: 34px; + max-width: 44vw; background-color: var(--bg-tertiary); border: 1px solid var(--border-color); border-radius: 6px; - overflow: hidden; -} - -.cluster-controls select { - height: 100%; - min-width: 92px; - padding: 0 8px; - background: transparent; - color: var(--text-primary); - border: 0; - border-right: 1px solid var(--border-color); - font-size: 14px; -} - -.cluster-controls select:focus { - outline: none; + overflow-x: auto; } .cluster-controls button { - min-width: 76px; + max-width: 150px; height: 100%; padding: 0 12px; background: transparent; color: var(--text-secondary); border: 0; + border-right: 1px solid var(--border-color); cursor: pointer; font-size: 14px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.cluster-controls button:last-child { + border-right: 0; } .cluster-controls button.active { @@ -173,6 +260,12 @@ body { color: var(--text-primary); } +.breadcrumb-btn::before { + content: "›"; + margin-right: 8px; + color: var(--text-secondary); +} + .renderer-toggle { display: inline-grid; grid-template-columns: 1fr 1fr; @@ -274,6 +367,19 @@ body { font-size: 14px; } +.focus-chip { + max-width: 220px; + padding: 4px 8px; + background-color: rgba(245, 158, 11, 0.18); + color: var(--text-primary); + border: 1px solid rgba(245, 158, 11, 0.45); + border-radius: 4px; + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .error-message { color: #f44; font-size: 14px; diff --git a/src/App.tsx b/src/App.tsx index e4995c3..7e8e5b2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,5 @@ import { useState, useEffect, useMemo, useCallback, useRef } from 'react' -import { invoke } from '@tauri-apps/api/core' +import { invoke, isTauri } from '@tauri-apps/api/core' import ForceGraph2D from 'react-force-graph-2d' import type { GraphData as ForceGraphData, NodeObject } from 'react-force-graph-2d' import { IcebugSigmaGraph, Sigma } from './vendor/sigma-runtime.js' @@ -50,6 +50,11 @@ interface GraphClusterLevel { clusters: GraphCluster[] } +interface ClusterPathItem { + level: number + clusterId: number +} + interface GraphClusterDebug { enabled: boolean status: string @@ -138,6 +143,13 @@ interface SigmaEdgeLabelNodeData { size: number } +function invokeCommand(cmd: string, args?: Record): Promise { + if (!isTauri()) { + return Promise.reject(new Error('Native app bridge is unavailable. Open this screen through the Tauri desktop app.')) + } + return invoke(cmd, args) +} + function getEndpointId(endpoint: string | NodeObject): string { return typeof endpoint === 'object' ? String(endpoint.id) : endpoint } @@ -201,62 +213,128 @@ function parseClusterNodeId(nodeId: string): { level: number; clusterId: number } } -function collapseGraphByClusterLevel( +function getCoarsestClusterLevel(clusterLevels: GraphClusterLevel[]) { + return clusterLevels.reduce((coarsest, level) => ( + !coarsest || level.level > coarsest.level ? level : coarsest + ), null) +} + +function getClusterLevel(clusterLevels: GraphClusterLevel[], level: number) { + return clusterLevels.find(item => item.level === level) || null +} + +function nodeMatchesClusterPath( + clusterLevels: GraphClusterLevel[], + nodeIndex: number, + clusterPath: ClusterPathItem[], +) { + return clusterPath.every(pathItem => ( + getClusterLevel(clusterLevels, pathItem.level)?.membership[nodeIndex] === pathItem.clusterId + )) +} + +function clusterBelongsToPath(cluster: GraphCluster, clusterPath: ClusterPathItem[]) { + const parent = clusterPath[clusterPath.length - 1] + return !parent || cluster.parentClusterId === parent.clusterId +} + +function getClusterPathForNode( + graphData: NormalizedGraphData, + clusterLevels: GraphClusterLevel[], + nodeId: string, +): ClusterPathItem[] { + const nodeIndex = graphData.nodes.findIndex(node => node.id === nodeId) + if (nodeIndex < 0) return [] + + return [...clusterLevels] + .sort((a, b) => b.level - a.level) + .map(level => ({ level: level.level, clusterId: level.membership[nodeIndex] })) + .filter(item => item.clusterId !== undefined) +} + +function buildClusterDrillGraph( graphData: NormalizedGraphData, - level: GraphClusterLevel, - expandedClusterId: number | null = null, + clusterLevels: GraphClusterLevel[], + clusterPath: ClusterPathItem[], ): NormalizedGraphData { - const clusterById = new Map(level.clusters.map(cluster => [cluster.clusterId, cluster])) - const clusterCounts = new Map() + const coarsestLevel = getCoarsestClusterLevel(clusterLevels) + if (!coarsestLevel) return graphData + + const parent = clusterPath[clusterPath.length - 1] + const currentLevelNumber = parent ? parent.level - 1 : coarsestLevel.level const nodeIndex = new Map(graphData.nodes.map((node, index) => [node.id, index])) + const eligibleNodeIndexes = new Set() - level.membership.forEach((clusterId, index) => { - if (isExpanderNode(graphData.nodes[index])) return - clusterCounts.set(clusterId, (clusterCounts.get(clusterId) || 0) + 1) + graphData.nodes.forEach((node, index) => { + if (isExpanderNode(node)) return + if (nodeMatchesClusterPath(clusterLevels, index, clusterPath)) { + eligibleNodeIndexes.add(index) + } }) - const collapsedNodes: GraphNode[] = [...clusterCounts.entries()] - .filter(([clusterId]) => clusterId !== expandedClusterId) - .sort(([a], [b]) => a - b) - .map(([clusterId, size]) => { - const cluster = clusterById.get(clusterId) - return { - id: getClusterNodeId(level.level, clusterId), - name: cluster?.label || `Cluster ${clusterId}`, - label: `Cluster L${level.level}`, - community: clusterId, - expansionKind: 'cluster', - hiddenCount: cluster?.size ?? size, + if (currentLevelNumber < 0) { + const nodes = graphData.nodes.filter((node, index) => { + if (isExpanderNode(node)) { + return graphData.links.some(link => ( + (link.source === node.id && eligibleNodeIndexes.has(nodeIndex.get(link.target) ?? -1)) + || (link.target === node.id && eligibleNodeIndexes.has(nodeIndex.get(link.source) ?? -1)) + )) } + return eligibleNodeIndexes.has(index) }) - - const expandedNodes = expandedClusterId === null - ? [] - : graphData.nodes.filter((node, index) => ( - !isExpanderNode(node) && level.membership[index] === expandedClusterId + const visibleNodeIds = new Set(nodes.map(node => node.id)) + const links = graphData.links.filter(link => ( + visibleNodeIds.has(link.source) && visibleNodeIds.has(link.target) )) - const expanderNodes = graphData.nodes.filter(isExpanderNode) - const nodes = [...collapsedNodes, ...expandedNodes, ...expanderNodes] - const visibleNodeIds = new Set(nodes.map(node => node.id)) - const edgeCounts = new Map; count: number }>() - const projectEndpoint = (nodeId: string, nodeIndexValue: number, clusterId: number) => { - const node = graphData.nodes[nodeIndexValue] - if (isExpanderNode(node) || clusterId === expandedClusterId) return nodeId - return getClusterNodeId(level.level, clusterId) + return { + nodes, + links, + csr: buildGraphCsr({ nodes, links }), + clusterLevels: graphData.clusterLevels, + clusterDebug: graphData.clusterDebug, + } } + const currentLevel = getClusterLevel(clusterLevels, currentLevelNumber) + if (!currentLevel) return graphData + + const clusterCounts = new Map() + eligibleNodeIndexes.forEach(index => { + const clusterId = currentLevel.membership[index] + clusterCounts.set(clusterId, (clusterCounts.get(clusterId) || 0) + 1) + }) + + const nodes: GraphNode[] = currentLevel.clusters + .filter(cluster => clusterCounts.has(cluster.clusterId)) + .filter(cluster => clusterBelongsToPath(cluster, clusterPath)) + .sort((a, b) => b.size - a.size || a.clusterId - b.clusterId) + .map(cluster => ({ + id: getClusterNodeId(currentLevel.level, cluster.clusterId), + name: cluster.label || `Cluster ${cluster.clusterId}`, + label: currentLevel.level === coarsestLevel.level ? 'Cluster' : `Cluster L${currentLevel.level}`, + community: cluster.clusterId, + expansionKind: 'cluster', + hiddenCount: clusterCounts.get(cluster.clusterId) ?? cluster.size, + })) + const visibleClusterIds = new Set(nodes.map(node => node.community).filter(id => id !== undefined)) + const visibleNodeIds = new Set(nodes.map(node => node.id)) + const edgeCounts = new Map; count: number }>() + graphData.links.forEach(link => { const sourceIndex = nodeIndex.get(link.source) const targetIndex = nodeIndex.get(link.target) if (sourceIndex === undefined || targetIndex === undefined) return + if (!eligibleNodeIndexes.has(sourceIndex) || !eligibleNodeIndexes.has(targetIndex)) return - const sourceCluster = level.membership[sourceIndex] - const targetCluster = level.membership[targetIndex] + const sourceCluster = currentLevel.membership[sourceIndex] + const targetCluster = currentLevel.membership[targetIndex] + if (sourceCluster === targetCluster) return + if (!visibleClusterIds.has(sourceCluster) || !visibleClusterIds.has(targetCluster)) return - const source = projectEndpoint(link.source, sourceIndex, sourceCluster) - const target = projectEndpoint(link.target, targetIndex, targetCluster) - if (source === target || !visibleNodeIds.has(source) || !visibleNodeIds.has(target)) return + const source = getClusterNodeId(currentLevel.level, sourceCluster) + const target = getClusterNodeId(currentLevel.level, targetCluster) + if (!visibleNodeIds.has(source) || !visibleNodeIds.has(target)) return const key = `${source}\t${target}` const record = edgeCounts.get(key) || { source, target, labels: new Map(), count: 0 } @@ -265,12 +343,11 @@ function collapseGraphByClusterLevel( edgeCounts.set(key, record) }) - const links = [...edgeCounts.values()].map(record => { - const label = record.count === 1 - ? [...record.labels.keys()][0] || 'edge' - : `${record.count} edges` - return { source: record.source, target: record.target, label } - }) + const links = [...edgeCounts.values()].map(record => ({ + source: record.source, + target: record.target, + label: record.count === 1 ? [...record.labels.keys()][0] || 'edge' : `${record.count} edges`, + })) return { nodes, @@ -652,9 +729,13 @@ function App() { const [isCustomQuery, setIsCustomQuery] = useState(false) const [queryActivated, setQueryActivated] = useState(false) const [renderer, setRenderer] = useState<'sigma' | 'force'>('sigma') - const [clusterCollapsed, setClusterCollapsed] = useState(false) - const [selectedClusterLevel, setSelectedClusterLevel] = useState(0) - const [expandedClusterId, setExpandedClusterId] = useState(null) + const [clusterPath, setClusterPath] = useState([]) + const [clusterViewEnabled, setClusterViewEnabled] = useState(true) + const [nodeSearch, setNodeSearch] = useState('') + const [searchResults, setSearchResults] = useState([]) + const [searching, setSearching] = useState(false) + const [searchError, setSearchError] = useState(null) + const [focusedNodeId, setFocusedNodeId] = useState(null) const [lastExpandedNodeIds, setLastExpandedNodeIds] = useState>(() => new Set()) // eslint-disable-next-line @typescript-eslint/no-explicit-any const graphRef = useRef(null) @@ -665,8 +746,8 @@ function App() { const fetchDatabases = () => { Promise.all([ - invoke('get_databases'), - invoke('get_initial_database_id'), + invokeCommand('get_databases'), + invokeCommand('get_initial_database_id'), ]) .then(([items, initialId]) => { setDatabases(items) @@ -679,7 +760,7 @@ function App() { const fetchDirectories = (dir: string) => { setPickerError(null) - invoke<{ current: string; parent: string; directories: { name: string; path: string; type: string }[]; files: { name: string; path: string; type: string }[] }>('get_directories', { path: dir || null }) + invokeCommand<{ current: string; parent: string; directories: { name: string; path: string; type: string }[]; files: { name: string; path: string; type: string }[] }>('get_directories', { path: dir || null }) .then(data => { setCurrentDir(data.current || dir || '') setParentDir(data.parent || '') @@ -738,12 +819,13 @@ function App() { const query = customQueryRef.current.trim() if (query) { - invoke('execute_query', { id: selectedId, query }) + invokeCommand('execute_query', { id: selectedId, query }) .then(data => { console.info('Graph cluster debug:', data.clusterDebug) setGraphData(data) setLastExpandedNodeIds(new Set()) - setExpandedClusterId(null) + setClusterPath([]) + setFocusedNodeId(null) setLoading(false) setTimeout(() => { if (graphRef.current) { @@ -756,12 +838,13 @@ function App() { setLoading(false) }) } else { - invoke('get_graph', { id: selectedId }) + invokeCommand('get_graph', { id: selectedId }) .then(data => { console.info('Graph cluster debug:', data.clusterDebug) setGraphData(data) setLastExpandedNodeIds(new Set()) - setExpandedClusterId(null) + setClusterPath([]) + setFocusedNodeId(null) setLoading(false) setTimeout(() => { if (graphRef.current) { @@ -795,7 +878,7 @@ function App() { const addDatabase = async (filePath: string) => { try { - await invoke('add_database', { filePath }) + await invokeCommand('add_database', { filePath }) fetchDatabases() setFilePickerOpen(false) setPickerError(null) @@ -805,6 +888,49 @@ function App() { } } + const runNodeSearch = useCallback(() => { + const query = nodeSearch.trim() + if (!query) { + setSearchResults([]) + setSearchError(null) + return + } + + setSearching(true) + setSearchError(null) + invokeCommand('search_nodes', { id: selectedId, query }) + .then(results => { + setSearchResults(results) + setSearching(false) + }) + .catch(err => { + setSearchError(String(err)) + setSearchResults([]) + setSearching(false) + }) + }, [nodeSearch, selectedId]) + + const exploreSearchResult = useCallback((node: GraphNode) => { + setLoading(true) + setError(null) + setSearchError(null) + invokeCommand('get_node_neighborhood', { id: selectedId, nodeId: node.id }) + .then(data => { + const normalized = normalizeGraphData(data) + const levels = buildCommunityClusterLevels(normalized) + setGraphData(data) + setClusterViewEnabled(levels.length > 0) + setClusterPath(getClusterPathForNode(normalized, levels, node.id)) + setFocusedNodeId(node.id) + setLastExpandedNodeIds(new Set([node.id])) + setLoading(false) + }) + .catch(err => { + setError(String(err)) + setLoading(false) + }) + }, [selectedId]) + const colorMapRef = useRef>({}) const edgeColorMapRef = useRef>({}) @@ -814,7 +940,7 @@ function App() { setLoading(true) setError(null) - invoke('expand_node', { + invokeCommand('expand_node', { id: selectedId, nodeId: node.expandNodeId, visibleNodeIds: graphData.nodes.map(item => item.id), @@ -829,7 +955,8 @@ function App() { }) setGraphData(data) setLastExpandedNodeIds(highlightedNodeIds) - setExpandedClusterId(null) + setClusterPath([]) + setFocusedNodeId(null) setLoading(false) }) .catch(err => { @@ -840,33 +967,40 @@ function App() { const normalizedGraphData = useMemo(() => normalizeGraphData(graphData), [graphData]) const clusterLevels = useMemo(() => buildCommunityClusterLevels(normalizedGraphData), [normalizedGraphData]) - const selectedCluster = useMemo(() => ( - clusterLevels.find(level => level.level === selectedClusterLevel) || clusterLevels[0] - ), [clusterLevels, selectedClusterLevel]) - const expandedCluster = useMemo(() => ( - expandedClusterId === null - ? null - : selectedCluster?.clusters.find(cluster => cluster.clusterId === expandedClusterId) || null - ), [expandedClusterId, selectedCluster]) + const coarsestClusterLevel = useMemo(() => getCoarsestClusterLevel(clusterLevels), [clusterLevels]) + const currentClusterLevel = useMemo(() => { + const parent = clusterPath[clusterPath.length - 1] + const currentLevelNumber = parent ? parent.level - 1 : coarsestClusterLevel?.level + return currentLevelNumber === undefined ? null : getClusterLevel(clusterLevels, currentLevelNumber) + }, [clusterLevels, clusterPath, coarsestClusterLevel]) + const clusterBreadcrumbs = useMemo(() => clusterPath.map(pathItem => { + const level = getClusterLevel(clusterLevels, pathItem.level) + const cluster = level?.clusters.find(item => item.clusterId === pathItem.clusterId) + return { + ...pathItem, + label: cluster?.label || `Cluster ${pathItem.clusterId}`, + size: cluster?.size, + } + }), [clusterLevels, clusterPath]) + const visibleClusterPath = useMemo(() => ( + clusterPath.filter(pathItem => getClusterLevel(clusterLevels, pathItem.level)) + ), [clusterLevels, clusterPath]) const visibleGraphData = useMemo(() => ( - clusterCollapsed && selectedCluster - ? collapseGraphByClusterLevel(normalizedGraphData, selectedCluster, expandedClusterId) + clusterViewEnabled && clusterLevels.length > 0 + ? buildClusterDrillGraph(normalizedGraphData, clusterLevels, visibleClusterPath) : normalizedGraphData - ), [clusterCollapsed, expandedClusterId, normalizedGraphData, selectedCluster]) + ), [clusterViewEnabled, clusterLevels, normalizedGraphData, visibleClusterPath]) /* eslint-disable react-hooks/set-state-in-effect */ useEffect(() => { - if (!selectedCluster) { - setClusterCollapsed(false) - setSelectedClusterLevel(0) - setExpandedClusterId(null) + if (clusterLevels.length === 0) { + setClusterPath([]) return } - if (!clusterLevels.some(level => level.level === selectedClusterLevel)) { - setSelectedClusterLevel(selectedCluster.level) - setExpandedClusterId(null) + if (visibleClusterPath.length !== clusterPath.length) { + setClusterPath(visibleClusterPath) } - }, [clusterLevels, selectedCluster, selectedClusterLevel]) + }, [clusterLevels.length, clusterPath, visibleClusterPath]) /* eslint-enable react-hooks/set-state-in-effect */ const forceGraphData = useMemo>(() => ({ @@ -895,9 +1029,10 @@ function App() { .filter(node => !isExpanderNode(node)) .slice(0, 5) .map(node => node.id), + ...(focusedNodeId ? [focusedNodeId] : []), ] ) - }, [lastExpandedNodeIds, visibleGraphData.nodes, nodeDegree]) + }, [lastExpandedNodeIds, visibleGraphData.nodes, nodeDegree, focusedNodeId]) const getNodeColor = useCallback((node: GraphNode) => { const key = node.community === undefined ? node.label : `community:${node.community}` @@ -925,9 +1060,12 @@ function App() { const handleVisibleNodeClick = useCallback((nodeId: string) => { const clusterNode = parseClusterNodeId(nodeId) if (clusterNode) { - setSelectedClusterLevel(clusterNode.level) - setExpandedClusterId(clusterNode.clusterId) - setClusterCollapsed(true) + setClusterViewEnabled(true) + setClusterPath(path => { + const parentIndex = path.findIndex(item => item.level <= clusterNode.level) + const basePath = parentIndex === -1 ? path : path.slice(0, parentIndex) + return [...basePath, clusterNode] + }) return } handleNodeClick(nodeId) @@ -1000,6 +1138,10 @@ function App() { className={`file-item ${selectedId === db.id ? 'active' : ''}`} onClick={() => { setSelectedId(db.id) + setNodeSearch('') + setSearchResults([]) + setSearchError(null) + setFocusedNodeId(null) }} title={db.relativePath} > @@ -1008,6 +1150,40 @@ function App() { ))} )} +
+
Find Node
+
+ setNodeSearch(event.target.value)} + onKeyDown={event => { + if (event.key === 'Enter') { + runNodeSearch() + } + }} + /> + +
+ {searchError &&
{searchError}
} + {searchResults.length > 0 && ( +
+ {searchResults.map(result => ( + + ))} +
+ )} +
@@ -1017,39 +1193,48 @@ function App() { {loading ? 'Loading...' - : clusterCollapsed && selectedCluster - ? expandedCluster - ? `${expandedCluster.label} expanded, ${visibleGraphData.nodes.length} visible nodes, ${visibleGraphData.links.length} visible edges` - : `${visibleGraphData.nodes.length} clusters, ${visibleGraphData.links.length} aggregate edges` + : clusterViewEnabled && clusterLevels.length > 0 + ? currentClusterLevel + ? `${visibleGraphData.nodes.length} clusters at level ${currentClusterLevel.level}, ${visibleGraphData.links.length} aggregate edges` + : `${visibleGraphData.nodes.length} nodes in cluster, ${visibleGraphData.links.length} edges` : `${graphData.nodes.length} nodes, ${graphData.links.length} edges`} + {focusedNodeId && Focused node {focusedNodeId}} {error && {error}}
{clusterLevels.length > 0 && (
- + Clusters + + {clusterBreadcrumbs.map((item, index) => ( + + ))}
)} From 46255c65e097de1a9367b2df21a6e5a86065607e Mon Sep 17 00:00:00 2001 From: Arun Sharma Date: Wed, 27 May 2026 11:38:26 -0700 Subject: [PATCH 2/4] Prefer FTS indexes for node search --- src-tauri/src/lib.rs | 90 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 601400a..ba2728b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -220,6 +220,22 @@ fn value_to_string(val: &Value) -> String { } } +fn value_to_score(val: &Value) -> f64 { + match val { + Value::Double(n) => *n, + Value::Float(n) => *n as f64, + Value::Int64(n) => *n as f64, + Value::Int32(n) => *n as f64, + Value::Int16(n) => *n as f64, + Value::Int8(n) => *n as f64, + _ => value_to_string(val).parse().unwrap_or(0.0), + } +} + +fn cypher_string(value: &str) -> String { + format!("'{}'", value.replace('\\', "\\\\").replace('\'', "\\'")) +} + fn graph_node_from_value(val: &Value) -> Option { let Value::Node(node_val) = val else { return None; @@ -264,6 +280,76 @@ fn node_value_matches_query(val: &Value, query: &str) -> bool { }) } +fn available_fts_indexes(conn: &Connection) -> Vec<(String, String)> { + let Ok(mut result) = conn.query("CALL SHOW_INDEXES() RETURN *") else { + return Vec::new(); + }; + + let mut indexes = Vec::new(); + for row in &mut result { + if row.len() < 3 { + continue; + } + + let table_name = value_to_string(&row[0]); + let index_name = value_to_string(&row[1]); + let index_type = value_to_string(&row[2]); + let extension_loaded = row + .get(4) + .map(|val| matches!(val, Value::Bool(true))) + .unwrap_or(true); + + if index_type.eq_ignore_ascii_case("FTS") && extension_loaded { + indexes.push((table_name, index_name)); + } + } + indexes +} + +fn search_nodes_with_fts(conn: &Connection, query: &str) -> Option> { + let indexes = available_fts_indexes(conn); + if indexes.is_empty() { + return None; + } + + let query_literal = cypher_string(query); + let mut scored_nodes: Vec<(GraphNode, f64)> = Vec::new(); + let mut seen = HashSet::new(); + + for (table_name, index_name) in indexes { + let cypher = format!( + "CALL QUERY_FTS_INDEX({}, {}, {}, top := {}) RETURN node, score ORDER BY score DESC", + cypher_string(&table_name), + cypher_string(&index_name), + query_literal, + SEARCH_RESULT_LIMIT, + ); + let Ok(mut result) = conn.query(&cypher) else { + continue; + }; + + for row in &mut result { + let Some(node) = row.first().and_then(graph_node_from_value) else { + continue; + }; + if !seen.insert(node.id.clone()) { + continue; + } + let score = row.get(1).map(value_to_score).unwrap_or(0.0); + scored_nodes.push((node, score)); + } + } + + scored_nodes.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)); + Some( + scored_nodes + .into_iter() + .map(|(node, _)| node) + .take(SEARCH_RESULT_LIMIT) + .collect(), + ) +} + fn id_to_string(id: &lbug::InternalID) -> String { format!("{}:{}", id.table_id, id.offset) } @@ -1084,6 +1170,10 @@ fn search_nodes( .map_err(|e| format!("Failed to open database: {}", e))?; let conn = Connection::new(&db).map_err(|e| format!("Failed to create connection: {}", e))?; + if let Some(fts_matches) = search_nodes_with_fts(&conn, query_text) { + return Ok(fts_matches); + } + let mut result = conn .query(&format!( "MATCH (n) RETURN n LIMIT {NODE_SEARCH_SCAN_LIMIT}" From 5612a93cf53da07cfe5448e399db739453dcc1e1 Mon Sep 17 00:00:00 2001 From: Arun Sharma Date: Wed, 27 May 2026 11:50:47 -0700 Subject: [PATCH 3/4] Send graph CSR as Arrow IPC --- package-lock.json | 1 + package.json | 1 + src-tauri/Cargo.lock | 48 +++++++++++++++++++++++++--- src-tauri/Cargo.toml | 6 ++-- src-tauri/src/lib.rs | 75 +++++++++++++++++++++++++++++++++++++++++--- src/App.tsx | 74 ++++++++++++++++++++++++++++++++++++++++--- 6 files changed, 191 insertions(+), 14 deletions(-) diff --git a/package-lock.json b/package-lock.json index 33338ab..609b799 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "0.15.1", "dependencies": { "@tauri-apps/api": "^2", + "apache-arrow": "^21.1.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-force-graph-2d": "^1.29.1", diff --git a/package.json b/package.json index e9662c1..07662ae 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ }, "dependencies": { "@tauri-apps/api": "^2", + "apache-arrow": "^21.1.0", "react": "^19.2.0", "react-dom": "^19.2.0", "react-force-graph-2d": "^1.29.1", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 59ca556..0e0d90d 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -87,7 +87,7 @@ dependencies = [ "arrow-ord", "arrow-row", "arrow-schema 55.2.0", - "arrow-select", + "arrow-select 55.2.0", "arrow-string", ] @@ -169,7 +169,7 @@ dependencies = [ "arrow-buffer 55.2.0", "arrow-data 55.2.0", "arrow-schema 55.2.0", - "arrow-select", + "arrow-select 55.2.0", "atoi", "base64 0.22.1", "chrono", @@ -203,6 +203,20 @@ dependencies = [ "num", ] +[[package]] +name = "arrow-ipc" +version = "56.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31f88b0fbb33af28089ccd3e4dcd0ff09de46842168d00220b920f7231feddf5" +dependencies = [ + "arrow-array 56.2.1", + "arrow-buffer 56.2.1", + "arrow-data 56.2.1", + "arrow-schema 56.2.1", + "arrow-select 56.2.1", + "flatbuffers", +] + [[package]] name = "arrow-ord" version = "55.2.0" @@ -213,7 +227,7 @@ dependencies = [ "arrow-buffer 55.2.0", "arrow-data 55.2.0", "arrow-schema 55.2.0", - "arrow-select", + "arrow-select 55.2.0", ] [[package]] @@ -258,6 +272,20 @@ dependencies = [ "num", ] +[[package]] +name = "arrow-select" +version = "56.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2368a78bd32902dba39d52519d70f63799c8b5dc8a9477129a30c2fd3dc70c19" +dependencies = [ + "ahash", + "arrow-array 56.2.1", + "arrow-buffer 56.2.1", + "arrow-data 56.2.1", + "arrow-schema 56.2.1", + "num", +] + [[package]] name = "arrow-string" version = "55.2.0" @@ -268,7 +296,7 @@ dependencies = [ "arrow-buffer 55.2.0", "arrow-data 55.2.0", "arrow-schema 55.2.0", - "arrow-select", + "arrow-select 55.2.0", "memchr", "num", "regex", @@ -414,6 +442,8 @@ name = "bugscope" version = "0.15.1" dependencies = [ "arrow-array 56.2.1", + "arrow-ipc", + "arrow-schema 56.2.1", "dirs 5.0.1", "icebug", "lbug", @@ -1153,6 +1183,16 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "flatbuffers" +version = "25.12.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35f6839d7b3b98adde531effaf34f0c2badc6f4735d26fe74709d8e513a96ef3" +dependencies = [ + "bitflags 2.11.1", + "rustc_version", +] + [[package]] name = "flate2" version = "1.1.9" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 172471a..40dad1f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -12,7 +12,7 @@ tauri-build = { version = "2", features = [] } [features] default = [] -icebug-analytics = ["dep:arrow-array", "dep:icebug"] +icebug-analytics = ["dep:icebug"] [dependencies] tauri = { version = "2", features = [] } @@ -20,6 +20,8 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" lbug = { git = "https://github.com/LadybugDB/ladybug-rust", features = ["arrow"] } icebug = { git = "https://github.com/Ladybug-Memory/icebug-rust", optional = true } -arrow-array = { version = "56", optional = true } +arrow-array = { version = "56" } +arrow-ipc = { version = "56" } +arrow-schema = { version = "56" } walkdir = "2" dirs = "5" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index ba2728b..3dbe259 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,12 +1,15 @@ -#[cfg(feature = "icebug-analytics")] -use arrow_array::UInt64Array; +use arrow_array::{builder::UInt64Builder, RecordBatch, UInt64Array}; +use arrow_ipc::writer::StreamWriter; +use arrow_schema::{DataType, Field, Schema}; #[cfg(feature = "icebug-analytics")] use icebug::{GraphR, Leiden}; use lbug::{Connection, Database, SystemConfig, Value}; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; use std::fs; +use std::io::Cursor; use std::path::{Path, PathBuf}; +use std::sync::Arc; use std::sync::Mutex; use tauri::{Manager, State}; use walkdir::WalkDir; @@ -94,6 +97,8 @@ struct GraphData { links: Vec, #[serde(skip_serializing_if = "Option::is_none")] csr: Option, + #[serde(rename = "csrArrowIpc", skip_serializing_if = "Option::is_none")] + csr_arrow_ipc: Option>, #[serde(rename = "clusterLevels", skip_serializing_if = "Option::is_none")] cluster_levels: Option>, #[serde(rename = "clusterDebug")] @@ -422,6 +427,57 @@ fn build_csr(nodes: &[GraphNode], links: &[GraphLink]) -> GraphCsr { } } +fn nullable_u64_column(values: &[u64], row_count: usize) -> UInt64Array { + let mut builder = UInt64Builder::with_capacity(row_count); + for index in 0..row_count { + if let Some(value) = values.get(index) { + builder.append_value(*value); + } else { + builder.append_null(); + } + } + builder.finish() +} + +fn csr_to_arrow_ipc(csr: &GraphCsr) -> Result, String> { + let row_count = [ + csr.indptr.len(), + csr.indices.len(), + csr.edge_ids.as_ref().map(|items| items.len()).unwrap_or(0), + ] + .into_iter() + .max() + .unwrap_or(0); + let edge_ids = csr.edge_ids.as_deref().unwrap_or(&[]); + let schema = Arc::new(Schema::new(vec![ + Field::new("indptr", DataType::UInt64, true), + Field::new("indices", DataType::UInt64, true), + Field::new("edge_ids", DataType::UInt64, true), + ])); + let batch = RecordBatch::try_new( + schema.clone(), + vec![ + Arc::new(nullable_u64_column(&csr.indptr, row_count)), + Arc::new(nullable_u64_column(&csr.indices, row_count)), + Arc::new(nullable_u64_column(edge_ids, row_count)), + ], + ) + .map_err(|e| format!("Failed to build Arrow CSR batch: {e}"))?; + + let mut output = Cursor::new(Vec::new()); + { + let mut writer = StreamWriter::try_new(&mut output, &schema) + .map_err(|e| format!("Failed to create Arrow CSR writer: {e}"))?; + writer + .write(&batch) + .map_err(|e| format!("Failed to write Arrow CSR batch: {e}"))?; + writer + .finish() + .map_err(|e| format!("Failed to finish Arrow CSR stream: {e}"))?; + } + Ok(output.into_inner()) +} + #[cfg(feature = "icebug-analytics")] fn build_undirected_csr( node_count: usize, @@ -797,13 +853,24 @@ fn compute_cluster_levels( } fn graph_data(nodes: Vec, links: Vec) -> GraphData { - let csr = Some(build_csr(&nodes, &links)); + let csr = build_csr(&nodes, &links); + let csr_arrow_ipc = csr_to_arrow_ipc(&csr) + .map_err(|err| { + eprintln!("{err}"); + err + }) + .ok(); let (cluster_levels, cluster_debug) = compute_cluster_levels(&nodes, &links); eprintln!("Cluster debug: {}", cluster_debug.message); GraphData { nodes, links, - csr, + csr: if csr_arrow_ipc.is_some() { + None + } else { + Some(csr) + }, + csr_arrow_ipc, cluster_levels, cluster_debug, } diff --git a/src/App.tsx b/src/App.tsx index 7e8e5b2..8b19676 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,5 +1,6 @@ import { useState, useEffect, useMemo, useCallback, useRef } from 'react' import { invoke, isTauri } from '@tauri-apps/api/core' +import { tableFromIPC } from 'apache-arrow' import ForceGraph2D from 'react-force-graph-2d' import type { GraphData as ForceGraphData, NodeObject } from 'react-force-graph-2d' import { IcebugSigmaGraph, Sigma } from './vendor/sigma-runtime.js' @@ -70,6 +71,7 @@ interface GraphData { nodes: GraphNode[] links: GraphLink[] csr?: GraphCsr + csrArrowIpc?: number[] | Uint8Array clusterLevels?: GraphClusterLevel[] clusterDebug?: GraphClusterDebug } @@ -84,10 +86,17 @@ interface NormalizedGraphData { nodes: GraphNode[] links: NormalizedGraphLink[] csr?: GraphCsr + csrArrowIpc?: number[] | Uint8Array clusterLevels?: GraphClusterLevel[] clusterDebug?: GraphClusterDebug } +interface GraphCsrArrays { + indptr: BigUint64Array + indices: BigUint64Array + edgeIds: BigUint64Array | null +} + interface ForceGraphLink { label: string } @@ -163,6 +172,7 @@ function normalizeGraphData(graphData: GraphData): NormalizedGraphData { label: link.label, })), csr: graphData.csr, + csrArrowIpc: graphData.csrArrowIpc, clusterLevels: graphData.clusterLevels, clusterDebug: graphData.clusterDebug, } @@ -393,6 +403,62 @@ function buildGraphCsr(graphData: NormalizedGraphData): GraphCsr { return { indptr, indices, edgeIds } } +function ipcBytesToUint8Array(bytes: number[] | Uint8Array) { + return bytes instanceof Uint8Array ? bytes : Uint8Array.from(bytes) +} + +function arrowColumnToBigUint64Array( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + table: any, + columnName: string, +): BigUint64Array { + const column = table.getChild(columnName) + if (!column) return new BigUint64Array() + + const values: bigint[] = [] + for (let index = 0; index < column.length; index += 1) { + const value = column.get(index) + if (value !== null && value !== undefined) { + values.push(BigInt(value)) + } + } + return new BigUint64Array(values) +} + +function decodeArrowCsr(graphData: NormalizedGraphData): GraphCsrArrays | null { + if (!graphData.csrArrowIpc) return null + + try { + const table = tableFromIPC(ipcBytesToUint8Array(graphData.csrArrowIpc)) + const indptr = arrowColumnToBigUint64Array(table, 'indptr') + const indices = arrowColumnToBigUint64Array(table, 'indices') + const edgeIds = arrowColumnToBigUint64Array(table, 'edge_ids') + if (indptr.length === graphData.nodes.length + 1 && indices.length === graphData.links.length) { + return { + indptr, + indices, + edgeIds: edgeIds.length === graphData.links.length ? edgeIds : null, + } + } + } catch (err) { + console.warn('Failed to decode Arrow CSR, falling back to JSON CSR', err) + } + + return null +} + +function graphCsrArrays(graphData: NormalizedGraphData): GraphCsrArrays { + const arrowCsr = decodeArrowCsr(graphData) + if (arrowCsr) return arrowCsr + + const csr = buildGraphCsr(graphData) + return { + indptr: new BigUint64Array(csr.indptr.map(BigInt)), + indices: new BigUint64Array(csr.indices.map(BigInt)), + edgeIds: csr.edgeIds ? new BigUint64Array(csr.edgeIds.map(BigInt)) : null, + } +} + function realNodeIds(nodes: GraphNode[]): Set { return new Set(nodes.filter(node => !isExpanderNode(node) && !isClusterNode(node)).map(node => node.id)) } @@ -615,15 +681,15 @@ function SigmaGraphView({ graphData, labelNodeIds, newlyExpandedNodeIds, darkMod edgeCounts.set(pairKey, pairIndex + 1) return `${pairKey}#${pairIndex}-${index}` }) - const csr = buildGraphCsr(graphData) + const csr = graphCsrArrays(graphData) return new IcebugSigmaGraph({ directed: true, nodes, csr: { - indptr: new BigUint64Array(csr.indptr.map(BigInt)), - indices: new BigUint64Array(csr.indices.map(BigInt)), - edgeIds: csr.edgeIds ? new BigUint64Array(csr.edgeIds.map(BigInt)) : null, + indptr: csr.indptr, + indices: csr.indices, + edgeIds: csr.edgeIds, }, edgeAttributes, edgeKeys, From 03e28a7bff31c2545f32bccfc183b5d10e8238b6 Mon Sep 17 00:00:00 2001 From: Arun Sharma Date: Wed, 27 May 2026 12:01:38 -0700 Subject: [PATCH 4/4] Refine cluster drilldown visuals --- src/App.tsx | 39 +++++++++++++++++++++++++++------------ 1 file changed, 27 insertions(+), 12 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 8b19676..ec85e5b 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -24,6 +24,7 @@ interface GraphNode { expandNodeId?: string offset?: number hiddenCount?: number + colorKey?: string } interface GraphLink { @@ -125,6 +126,7 @@ interface SigmaGraphViewProps { newlyExpandedNodeIds: Set darkMode: boolean getNodeColor: (node: GraphNode) => string + getNodeSize: (node: GraphNode) => number getEdgeColor: (label: string) => string onNodeClick: (nodeId: string) => void } @@ -283,6 +285,7 @@ function buildClusterDrillGraph( }) if (currentLevelNumber < 0) { + const finestLevel = getClusterLevel(clusterLevels, 0) const nodes = graphData.nodes.filter((node, index) => { if (isExpanderNode(node)) { return graphData.links.some(link => ( @@ -291,6 +294,13 @@ function buildClusterDrillGraph( )) } return eligibleNodeIndexes.has(index) + }).map((node, index) => { + if (isExpanderNode(node)) return node + const sourceIndex = nodeIndex.get(node.id) ?? index + const clusterId = finestLevel?.membership[sourceIndex] + return clusterId === undefined + ? node + : { ...node, community: clusterId, colorKey: `cluster:0:${clusterId}` } }) const visibleNodeIds = new Set(nodes.map(node => node.id)) const links = graphData.links.filter(link => ( @@ -324,6 +334,7 @@ function buildClusterDrillGraph( name: cluster.label || `Cluster ${cluster.clusterId}`, label: currentLevel.level === coarsestLevel.level ? 'Cluster' : `Cluster L${currentLevel.level}`, community: cluster.clusterId, + colorKey: `cluster:${currentLevel.level}:${cluster.clusterId}`, expansionKind: 'cluster', hiddenCount: clusterCounts.get(cluster.clusterId) ?? cluster.size, })) @@ -639,23 +650,21 @@ function createInitialLayout(graphData: NormalizedGraphData) { return { degrees, positions } } -function SigmaGraphView({ graphData, labelNodeIds, newlyExpandedNodeIds, darkMode, getNodeColor, getEdgeColor, onNodeClick }: SigmaGraphViewProps) { +function SigmaGraphView({ graphData, labelNodeIds, newlyExpandedNodeIds, darkMode, getNodeColor, getNodeSize, getEdgeColor, onNodeClick }: SigmaGraphViewProps) { const containerRef = useRef(null) const rendererRef = useRef(null) const hoveredEdgeRef = useRef(null) const graph = useMemo(() => { - const { degrees, positions } = createInitialLayout(graphData) - const maxDegree = Math.max(1, ...Object.values(degrees)) + const { positions } = createInitialLayout(graphData) const nodes = graphData.nodes.map(node => { const position = positions[node.id] || { x: 0, y: 0 } - const degree = degrees[node.id] || 0 return { key: node.id, attributes: { x: position.x, y: position.y, - size: 4 + (degree / maxDegree) * 14, + size: getNodeSize(node), color: isExpanderNode(node) ? '#f59e0b' : getNodeColor(node), label: isExpanderNode(node) || labelNodeIds.has(node.id) ? node.name || node.id : '', hoverLabel: node.name || node.id, @@ -694,7 +703,7 @@ function SigmaGraphView({ graphData, labelNodeIds, newlyExpandedNodeIds, darkMod edgeAttributes, edgeKeys, }) - }, [graphData, labelNodeIds, newlyExpandedNodeIds, getNodeColor, getEdgeColor]) + }, [graphData, labelNodeIds, newlyExpandedNodeIds, getNodeColor, getNodeSize, getEdgeColor]) useEffect(() => { const container = containerRef.current @@ -1003,26 +1012,29 @@ function App() { const handleNodeClick = useCallback((nodeId: string) => { const node = graphData.nodes.find(item => item.id === nodeId) if (!node?.expansionKind || node.expansionKind !== 'node' || !node.expandNodeId) return + const expandedNodeId = node.expandNodeId setLoading(true) setError(null) invokeCommand('expand_node', { id: selectedId, - nodeId: node.expandNodeId, + nodeId: expandedNodeId, visibleNodeIds: graphData.nodes.map(item => item.id), offset: node.offset ?? 0, }) .then(data => { const beforeNodeIds = realNodeIds(graphData.nodes) const highlightedNodeIds = realNodeIds(data.nodes) + const normalized = normalizeGraphData(data) + const levels = buildCommunityClusterLevels(normalized) beforeNodeIds.forEach(id => { highlightedNodeIds.delete(id) }) setGraphData(data) setLastExpandedNodeIds(highlightedNodeIds) - setClusterPath([]) - setFocusedNodeId(null) + setClusterPath(getClusterPathForNode(normalized, levels, expandedNodeId)) + setFocusedNodeId(expandedNodeId) setLoading(false) }) .catch(err => { @@ -1101,7 +1113,7 @@ function App() { }, [lastExpandedNodeIds, visibleGraphData.nodes, nodeDegree, focusedNodeId]) const getNodeColor = useCallback((node: GraphNode) => { - const key = node.community === undefined ? node.label : `community:${node.community}` + const key = node.colorKey || (node.community === undefined ? node.label : `${node.label}:${node.community}`) if (!colorMapRef.current[key]) { const colors = ['#4e79a7', '#f28e2c', '#e15759', '#76b7b2', '#59a14f', '#edc949', '#af7aa1', '#ff9da7', '#9c755f', '#bab0ab'] colorMapRef.current[key] = colors[Object.keys(colorMapRef.current).length % colors.length] @@ -1118,9 +1130,11 @@ function App() { }, []) const getNodeSize = useCallback((node: GraphNode) => { + if (node.expansionKind === 'cluster') { + return Math.min(34, 5 + Math.sqrt(node.hiddenCount || 1) * 3) + } const degree = nodeDegree[node.id] || 0 - const clusterBoost = node.expansionKind === 'cluster' ? Math.min(10, Math.sqrt(node.hiddenCount || 1)) : 0 - return 4 + clusterBoost + (degree / maxDegree) * 12 + return 4 + (degree / maxDegree) * 12 }, [nodeDegree, maxDegree]) const handleVisibleNodeClick = useCallback((nodeId: string) => { @@ -1336,6 +1350,7 @@ function App() { newlyExpandedNodeIds={lastExpandedNodeIds} darkMode={darkMode} getNodeColor={getNodeColor} + getNodeSize={getNodeSize} getEdgeColor={getEdgeColor} onNodeClick={handleVisibleNodeClick} />