From b273deb3808156b7369fc22586648197e003a08a Mon Sep 17 00:00:00 2001 From: Arun Sharma Date: Sun, 24 May 2026 21:21:49 -0700 Subject: [PATCH 01/10] Use CSR-backed Sigma graph rendering --- package-lock.json | 24 --- package.json | 1 - src-tauri/Cargo.lock | 388 +++++++++++++++++++++++++++++++++- src-tauri/Cargo.toml | 2 +- src-tauri/src/lib.rs | 79 ++++++- src/App.tsx | 191 ++++++++--------- src/vendor/sigma-runtime.d.ts | 24 +++ src/vendor/sigma-runtime.js | 3 +- 8 files changed, 575 insertions(+), 137 deletions(-) diff --git a/package-lock.json b/package-lock.json index 8308d5e..33338ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,6 @@ "name": "bugscope", "version": "0.15.1", "dependencies": { - "@ladybugmem/icebug": "^12.8.0", "@tauri-apps/api": "^2", "react": "^19.2.0", "react-dom": "^19.2.0", @@ -34,7 +33,6 @@ "version": "3.0.3", "license": "MIT", "dependencies": { - "@ladybugmem/icebug": "^12.7.0", "apache-arrow": "^21.1.0", "events": "^3.3.0" }, @@ -1584,19 +1582,6 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, - "node_modules/@ladybugmem/icebug": { - "version": "12.8.0", - "resolved": "https://registry.npmjs.org/@ladybugmem/icebug/-/icebug-12.8.0.tgz", - "integrity": "sha512-9gAi0d/T5x2EW9e3leJO5A/fDeqHsvw4Z0/Np4q2PVyH662fz2ZqIwtnEjqItoGXAf5o19PaXYB0WzGJ2rCWGQ==", - "hasInstallScript": true, - "license": "MIT", - "dependencies": { - "node-addon-api": "^8.0.0" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-rc.3", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-rc.3.tgz", @@ -3791,15 +3776,6 @@ "dev": true, "license": "MIT" }, - "node_modules/node-addon-api": { - "version": "8.8.0", - "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.8.0.tgz", - "integrity": "sha512-c5Ko1fZJIJmzhFIkhRN76WTq+fC6tWnGy9CXA0fA+XygsWZmEwG8vmbkNqxMyoaa0Tin4djul49NzdVcJJcjeA==", - "license": "MIT", - "engines": { - "node": "^18 || ^20 || >= 21" - } - }, "node_modules/node-releases": { "version": "2.0.27", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", diff --git a/package.json b/package.json index a4229fa..b668826 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,6 @@ "preview": "vite preview" }, "dependencies": { - "@ladybugmem/icebug": "^12.8.0", "@tauri-apps/api": "^2", "react": "^19.2.0", "react-dom": "^19.2.0", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index b9878bb..8418349 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -8,6 +8,20 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "const-random", + "getrandom 0.3.4", + "once_cell", + "version_check", + "zerocopy", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -59,6 +73,163 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +[[package]] +name = "arrow" +version = "55.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3f15b4c6b148206ff3a2b35002e08929c2462467b62b9c02036d9c34f9ef994" +dependencies = [ + "arrow-arith", + "arrow-array", + "arrow-buffer", + "arrow-cast", + "arrow-data", + "arrow-ord", + "arrow-row", + "arrow-schema", + "arrow-select", + "arrow-string", +] + +[[package]] +name = "arrow-arith" +version = "55.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30feb679425110209ae35c3fbf82404a39a4c0436bb3ec36164d8bffed2a4ce4" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "num", +] + +[[package]] +name = "arrow-array" +version = "55.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70732f04d285d49054a48b72c54f791bb3424abae92d27aafdf776c98af161c8" +dependencies = [ + "ahash", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "chrono", + "half", + "hashbrown 0.15.5", + "num", +] + +[[package]] +name = "arrow-buffer" +version = "55.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "169b1d5d6cb390dd92ce582b06b23815c7953e9dfaaea75556e89d890d19993d" +dependencies = [ + "bytes", + "half", + "num", +] + +[[package]] +name = "arrow-cast" +version = "55.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4f12eccc3e1c05a766cafb31f6a60a46c2f8efec9b74c6e0648766d30686af8" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", + "atoi", + "base64 0.22.1", + "chrono", + "half", + "lexical-core", + "num", + "ryu", +] + +[[package]] +name = "arrow-data" +version = "55.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de1ce212d803199684b658fc4ba55fb2d7e87b213de5af415308d2fee3619c2" +dependencies = [ + "arrow-buffer", + "arrow-schema", + "half", + "num", +] + +[[package]] +name = "arrow-ord" +version = "55.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6506e3a059e3be23023f587f79c82ef0bcf6d293587e3272d20f2d30b969b5a7" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", +] + +[[package]] +name = "arrow-row" +version = "55.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52bf7393166beaf79b4bed9bfdf19e97472af32ce5b6b48169d321518a08cae2" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "half", +] + +[[package]] +name = "arrow-schema" +version = "55.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af7686986a3bf2254c9fb130c623cdcb2f8e1f15763e7c71c310f0834da3d292" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "arrow-select" +version = "55.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd2b45757d6a2373faa3352d02ff5b54b098f5e21dccebc45a21806bc34501e5" +dependencies = [ + "ahash", + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "num", +] + +[[package]] +name = "arrow-string" +version = "55.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0377d532850babb4d927a06294314b316e23311503ed580ec6ce6a0158f49d40" +dependencies = [ + "arrow-array", + "arrow-buffer", + "arrow-data", + "arrow-schema", + "arrow-select", + "memchr", + "num", + "regex", + "regex-syntax", +] + [[package]] name = "atk" version = "0.18.2" @@ -82,6 +253,15 @@ dependencies = [ "system-deps", ] +[[package]] +name = "atoi" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28d99ec8bfea296261ca1af174f24225171fea9664ba9003cbebee704810528" +dependencies = [ + "num-traits", +] + [[package]] name = "atomic-waker" version = "1.1.2" @@ -401,6 +581,26 @@ dependencies = [ "memchr", ] +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + [[package]] name = "cookie" version = "0.18.1" @@ -484,6 +684,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -717,7 +923,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1328,6 +1534,18 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "num-traits", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1767,8 +1985,9 @@ dependencies = [ [[package]] name = "lbug" version = "0.17.0" -source = "git+https://github.com/LadybugDB/ladybug-rust#fcf1f94b10dffb4e0d11aa0b4822f3a85721abb1" +source = "git+https://github.com/LadybugDB/ladybug-rust#1e35ceb4cf4c5feeeede23ca5e4551274ade4d31" dependencies = [ + "arrow", "cmake", "cxx", "cxx-build", @@ -1785,6 +2004,63 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" +[[package]] +name = "lexical-core" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8d125a277f807e55a77304455eb7b1cb52f2b18c143b60e766c120bd64a594" +dependencies = [ + "lexical-parse-float", + "lexical-parse-integer", + "lexical-util", + "lexical-write-float", + "lexical-write-integer", +] + +[[package]] +name = "lexical-parse-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52a9f232fbd6f550bc0137dcb5f99ab674071ac2d690ac69704593cb4abbea56" +dependencies = [ + "lexical-parse-integer", + "lexical-util", +] + +[[package]] +name = "lexical-parse-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a7a039f8fb9c19c996cd7b2fcce303c1b2874fe1aca544edc85c4a5f8489b34" +dependencies = [ + "lexical-util", +] + +[[package]] +name = "lexical-util" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2604dd126bb14f13fb5d1bd6a66155079cb9fa655b37f875b3a742c705dbed17" + +[[package]] +name = "lexical-write-float" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50c438c87c013188d415fbabbb1dceb44249ab81664efbd31b14ae55dabb6361" +dependencies = [ + "lexical-util", + "lexical-write-integer", +] + +[[package]] +name = "lexical-write-integer" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "409851a618475d2d5796377cad353802345cba92c867d9fbcde9cf4eac4e14df" +dependencies = [ + "lexical-util", +] + [[package]] name = "libappindicator" version = "0.9.0" @@ -1834,6 +2110,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + [[package]] name = "libredox" version = "0.1.16" @@ -1977,12 +2259,76 @@ version = "1.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-conv" version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-iter" +version = "0.1.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +dependencies = [ + "autocfg", + "num-integer", + "num-traits", +] + +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -1990,6 +2336,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -2653,6 +3000,12 @@ version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + [[package]] name = "same-file" version = "1.0.6" @@ -3472,6 +3825,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -4163,7 +4525,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -4780,6 +5142,26 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + [[package]] name = "zerofrom" version = "0.1.8" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index e99dcca..6dec01a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -14,6 +14,6 @@ tauri-build = { version = "2", features = [] } tauri = { version = "2", features = [] } serde = { version = "1", features = ["derive"] } serde_json = "1" -lbug = { git = "https://github.com/LadybugDB/ladybug-rust" } +lbug = { git = "https://github.com/LadybugDB/ladybug-rust", features = ["arrow"] } walkdir = "2" dirs = "5" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index aeb1055..d1dd401 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -21,6 +21,12 @@ struct GraphNode { id: String, name: String, label: String, + #[serde(rename = "tableId", skip_serializing_if = "Option::is_none")] + table_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + rowid: Option, + #[serde(skip_serializing_if = "Option::is_none")] + community: Option, #[serde(rename = "expansionKind", skip_serializing_if = "Option::is_none")] expansion_kind: Option, #[serde(rename = "expandNodeId", skip_serializing_if = "Option::is_none")] @@ -38,10 +44,20 @@ struct GraphLink { label: String, } +#[derive(Debug, Clone, Serialize, Deserialize)] +struct GraphCsr { + indptr: Vec, + indices: Vec, + #[serde(rename = "edgeIds")] + edge_ids: Option>, +} + #[derive(Debug, Clone, Serialize, Deserialize)] struct GraphData { nodes: Vec, links: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + csr: Option, } const SEED_NODE_COUNT: usize = 8; @@ -168,6 +184,9 @@ fn make_expander_node(parent_id: &str, hidden_count: usize, offset: usize) -> Gr id: format!("{EXPANDER_PREFIX}node:{parent_id}:{offset}"), name: format!("+{hidden_count}"), label: "More".to_string(), + table_id: None, + rowid: None, + community: None, expansion_kind: Some("node".to_string()), expand_node_id: Some(parent_id.to_string()), offset: Some(offset), @@ -186,6 +205,49 @@ fn merge_link(links: &mut Vec, seen: &mut HashSet<(String, String, St } } +fn build_csr(nodes: &[GraphNode], links: &[GraphLink]) -> GraphCsr { + let node_index: HashMap<&str, usize> = nodes + .iter() + .enumerate() + .map(|(index, node)| (node.id.as_str(), index)) + .collect(); + let mut outgoing: Vec> = vec![Vec::new(); nodes.len()]; + + for (edge_index, link) in links.iter().enumerate() { + let Some(&source) = node_index.get(link.source.as_str()) else { + continue; + }; + let Some(&target) = node_index.get(link.target.as_str()) else { + continue; + }; + outgoing[source].push((target, edge_index)); + } + + let mut indptr = Vec::with_capacity(nodes.len() + 1); + let mut indices = Vec::new(); + let mut edge_ids = Vec::new(); + + for neighbors in outgoing { + indptr.push(indices.len() as u64); + for (target, edge_index) in neighbors { + indices.push(target as u64); + edge_ids.push(edge_index as u64); + } + } + indptr.push(indices.len() as u64); + + GraphCsr { + indptr, + indices, + edge_ids: Some(edge_ids), + } +} + +fn graph_data(nodes: Vec, links: Vec) -> GraphData { + let csr = Some(build_csr(&nodes, &links)); + GraphData { nodes, links, csr } +} + fn collect_edge_graph(conn: &Connection, limit: usize) -> Result { let mut result = conn .query(&format!("MATCH (a)-[r]->(b) RETURN a, r, b LIMIT {limit}")) @@ -223,6 +285,9 @@ fn collect_edge_graph(conn: &Connection, limit: usize) -> Result Result, nodes: &mut Vec, links: &mut Vec) { @@ -315,7 +377,7 @@ fn seed_graph_from_full(full_graph: GraphData) -> GraphData { .collect(); add_expanders(&full_graph, &visible_ids, &mut nodes, &mut links); - GraphData { nodes, links } + graph_data(nodes, links) } fn expand_node_from_full(full_graph: GraphData, node_id: &str, visible_node_ids: &[String], offset: usize) -> GraphData { @@ -387,7 +449,7 @@ fn expand_node_from_full(full_graph: GraphData, node_id: &str, visible_node_ids: nodes.push(expander); } - GraphData { nodes, links } + graph_data(nodes, links) } #[tauri::command] @@ -566,6 +628,9 @@ fn execute_query(state: State, id: usize, query: String) -> Result, id: usize, query: String) -> Result newlyExpandedNodeIds: Set darkMode: boolean - getNodeColor: (label: string) => string + getNodeColor: (node: GraphNode) => string getEdgeColor: (label: string) => string onNodeClick: (nodeId: string) => void } @@ -99,76 +110,6 @@ interface SigmaEdgeLabelNodeData { size: number } -class SigmaGraph< - N extends Record = Record, - E extends Record = Record, -> { - private nodeAttributes = new Map() - private edgeRecords = new Map() - - get order() { - return this.nodeAttributes.size - } - - addNode(key: string, attributes = {} as N): void { - if (this.nodeAttributes.has(key)) throw new Error(`SigmaGraph: node "${key}" already exists.`) - this.nodeAttributes.set(key, attributes) - } - - addEdge(key: string, source: string, target: string, attributes = {} as E): void { - if (this.edgeRecords.has(key)) throw new Error(`SigmaGraph: edge "${key}" already exists.`) - if (!this.nodeAttributes.has(source)) this.addNode(source) - if (!this.nodeAttributes.has(target)) this.addNode(target) - this.edgeRecords.set(key, { source, target, attributes }) - } - - hasNode(key: string): boolean { - return this.nodeAttributes.has(key) - } - - hasEdge(key: string): boolean { - return this.edgeRecords.has(key) - } - - nodes(): string[] { - return [...this.nodeAttributes.keys()] - } - - edges(): string[] { - return [...this.edgeRecords.keys()] - } - - forEachNode(callback: (key: string, attributes: N) => void): void { - this.nodeAttributes.forEach((attributes, key) => callback(key, attributes)) - } - - forEachEdge(callback: (key: string, attributes: E) => void): void { - this.edgeRecords.forEach(({ attributes }, key) => callback(key, attributes)) - } - - getNodeAttributes(key: string): N { - const attributes = this.nodeAttributes.get(key) - if (!attributes) throw new Error(`SigmaGraph: node "${key}" not found.`) - return attributes - } - - getEdgeAttributes(key: string): E { - const record = this.edgeRecords.get(key) - if (!record) throw new Error(`SigmaGraph: edge "${key}" not found.`) - return record.attributes - } - - extremities(key: string): [string, string] { - const record = this.edgeRecords.get(key) - if (!record) throw new Error(`SigmaGraph: edge "${key}" not found.`) - return [record.source, record.target] - } - - on(): void {} - - removeListener(): void {} -} - function getEndpointId(endpoint: string | NodeObject): string { return typeof endpoint === 'object' ? String(endpoint.id) : endpoint } @@ -181,6 +122,7 @@ function normalizeGraphData(graphData: GraphData): NormalizedGraphData { target: getEndpointId(link.target), label: link.label, })), + csr: graphData.csr, } } @@ -211,6 +153,41 @@ function mergeGraphData(current: GraphData, incoming: GraphData, expandedNodeId? } } +function buildGraphCsr(graphData: NormalizedGraphData): GraphCsr { + if ( + graphData.csr && + graphData.csr.indptr.length === graphData.nodes.length + 1 && + graphData.csr.indices.length === graphData.links.length + ) { + return graphData.csr + } + + const nodeIndex = new Map(graphData.nodes.map((node, index) => [node.id, index])) + const outgoing: Array> = Array.from({ length: graphData.nodes.length }, () => []) + + graphData.links.forEach((link, edgeIndex) => { + const sourceIndex = nodeIndex.get(link.source) + const targetIndex = nodeIndex.get(link.target) + if (sourceIndex === undefined || targetIndex === undefined) return + outgoing[sourceIndex].push({ target: targetIndex, edgeIndex }) + }) + + const indptr: number[] = [] + const indices: number[] = [] + const edgeIds: number[] = [] + + outgoing.forEach(neighbors => { + indptr.push(indices.length) + neighbors.forEach(({ target, edgeIndex }) => { + indices.push(target) + edgeIds.push(edgeIndex) + }) + }) + indptr.push(indices.length) + + return { indptr, indices, edgeIds } +} + function realNodeIds(nodes: GraphNode[]): Set { return new Set(nodes.filter(node => !isExpanderNode(node)).map(node => node.id)) } @@ -399,40 +376,53 @@ function SigmaGraphView({ graphData, labelNodeIds, newlyExpandedNodeIds, darkMod const graph = useMemo(() => { const { degrees, positions } = createInitialLayout(graphData) const maxDegree = Math.max(1, ...Object.values(degrees)) - const sigmaGraph = new SigmaGraph() - - graphData.nodes.forEach(node => { + const nodes = graphData.nodes.map(node => { const position = positions[node.id] || { x: 0, y: 0 } const degree = degrees[node.id] || 0 - sigmaGraph.addNode(node.id, { - x: position.x, - y: position.y, - size: 4 + (degree / maxDegree) * 14, - color: isExpanderNode(node) ? '#f59e0b' : getNodeColor(node.label), - label: isExpanderNode(node) || labelNodeIds.has(node.id) ? node.name || node.id : '', - hoverLabel: node.name || node.id, - isNewlyExpanded: newlyExpandedNodeIds.has(node.id), - nodeType: node.label, - }) + return { + key: node.id, + attributes: { + x: position.x, + y: position.y, + size: 4 + (degree / maxDegree) * 14, + color: isExpanderNode(node) ? '#f59e0b' : getNodeColor(node), + label: isExpanderNode(node) || labelNodeIds.has(node.id) ? node.name || node.id : '', + hoverLabel: node.name || node.id, + isNewlyExpanded: newlyExpandedNodeIds.has(node.id), + nodeType: node.label, + }, + } }) const edgeCounts = new Map() - graphData.links.forEach((link, index) => { - if (!sigmaGraph.hasNode(link.source) || !sigmaGraph.hasNode(link.target)) return - const pairKey = `${link.source}->${link.target}` - const pairIndex = edgeCounts.get(pairKey) || 0 - edgeCounts.set(pairKey, pairIndex + 1) - const edgeKey = `${pairKey}#${pairIndex}-${index}` + const edgeAttributes: SigmaEdgeAttributes[] = graphData.links.map(link => { const edgeLabel = link.label === 'more' ? '' : link.label || '' - sigmaGraph.addEdge(edgeKey, link.source, link.target, { + return { size: 1.8, color: getEdgeColor(link.label || 'edge'), label: edgeLabel, forceLabel: Boolean(edgeLabel), - }) + } + }) + const edgeKeys: string[] = graphData.links.map((link, index) => { + const pairKey = `${link.source}->${link.target}` + const pairIndex = edgeCounts.get(pairKey) || 0 + edgeCounts.set(pairKey, pairIndex + 1) + return `${pairKey}#${pairIndex}-${index}` + }) + const csr = buildGraphCsr(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, + }, + edgeAttributes, + edgeKeys, }) - - return sigmaGraph }, [graphData, labelNodeIds, newlyExpandedNodeIds, getNodeColor, getEdgeColor]) useEffect(() => { @@ -745,12 +735,13 @@ function App() { ) }, [lastExpandedNodeIds, normalizedGraphData.nodes, nodeDegree]) - const getNodeColor = useCallback((label: string) => { - if (!colorMapRef.current[label]) { + const getNodeColor = useCallback((node: GraphNode) => { + const key = node.community === undefined ? node.label : `community:${node.community}` + if (!colorMapRef.current[key]) { const colors = ['#4e79a7', '#f28e2c', '#e15759', '#76b7b2', '#59a14f', '#edc949', '#af7aa1', '#ff9da7', '#9c755f', '#bab0ab'] - colorMapRef.current[label] = colors[Object.keys(colorMapRef.current).length % colors.length] + colorMapRef.current[key] = colors[Object.keys(colorMapRef.current).length % colors.length] } - return colorMapRef.current[label] + return colorMapRef.current[key] }, []) const getEdgeColor = useCallback((label: string) => { @@ -769,7 +760,7 @@ function App() { // eslint-disable-next-line @typescript-eslint/no-explicit-any const paintNode = useCallback((node: any, ctx: CanvasRenderingContext2D) => { const size = getNodeSize(node) - const color = isExpanderNode(node) ? '#f59e0b' : getNodeColor(node.label) + const color = isExpanderNode(node) ? '#f59e0b' : getNodeColor(node) const highlighted = lastExpandedNodeIds.has(node.id) if (highlighted) { diff --git a/src/vendor/sigma-runtime.d.ts b/src/vendor/sigma-runtime.d.ts index c528130..83ae103 100644 --- a/src/vendor/sigma-runtime.d.ts +++ b/src/vendor/sigma-runtime.d.ts @@ -51,3 +51,27 @@ export class Sigma { refresh(): void kill(): void } + +export type IcebugCSRArray = number[] | Uint32Array | BigUint64Array + +export interface IcebugSigmaGraphOptions< + N extends Record = Record, + E extends Record = Record, +> { + directed?: boolean + nodes: Array<{ key: string; attributes: N }> + csr: { + indptr: IcebugCSRArray + indices: IcebugCSRArray + edgeIds?: IcebugCSRArray | null + } + edgeAttributes?: E[] + edgeKeys?: string[] +} + +export class IcebugSigmaGraph< + N extends Record = Record, + E extends Record = Record, +> { + constructor(options: IcebugSigmaGraphOptions) +} diff --git a/src/vendor/sigma-runtime.js b/src/vendor/sigma-runtime.js index 660eba0..3b0e05d 100644 --- a/src/vendor/sigma-runtime.js +++ b/src/vendor/sigma-runtime.js @@ -1,3 +1,4 @@ import Sigma from 'sigma' +import { IcebugSigmaGraph } from 'sigma/icebug' -export { Sigma } +export { Sigma, IcebugSigmaGraph } From 0c54bc9691558d0ab5bb5e6ab7fa3ea982c7632d Mon Sep 17 00:00:00 2001 From: Arun Sharma Date: Mon, 25 May 2026 08:05:09 -0700 Subject: [PATCH 02/10] Add Icebug-backed cluster levels --- src-tauri/Cargo.lock | 135 +++++++++++++++++------- src-tauri/Cargo.toml | 2 + src-tauri/src/lib.rs | 238 ++++++++++++++++++++++++++++++++++++++++++- src/App.css | 45 ++++++++ src/App.tsx | 183 ++++++++++++++++++++++++++++++--- 5 files changed, 554 insertions(+), 49 deletions(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 8418349..b58bf49 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -80,13 +80,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f3f15b4c6b148206ff3a2b35002e08929c2462467b62b9c02036d9c34f9ef994" dependencies = [ "arrow-arith", - "arrow-array", - "arrow-buffer", + "arrow-array 55.2.0", + "arrow-buffer 55.2.0", "arrow-cast", - "arrow-data", + "arrow-data 55.2.0", "arrow-ord", "arrow-row", - "arrow-schema", + "arrow-schema 55.2.0", "arrow-select", "arrow-string", ] @@ -97,10 +97,10 @@ version = "55.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "30feb679425110209ae35c3fbf82404a39a4c0436bb3ec36164d8bffed2a4ce4" dependencies = [ - "arrow-array", - "arrow-buffer", - "arrow-data", - "arrow-schema", + "arrow-array 55.2.0", + "arrow-buffer 55.2.0", + "arrow-data 55.2.0", + "arrow-schema 55.2.0", "chrono", "num", ] @@ -112,15 +112,31 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "70732f04d285d49054a48b72c54f791bb3424abae92d27aafdf776c98af161c8" dependencies = [ "ahash", - "arrow-buffer", - "arrow-data", - "arrow-schema", + "arrow-buffer 55.2.0", + "arrow-data 55.2.0", + "arrow-schema 55.2.0", "chrono", "half", "hashbrown 0.15.5", "num", ] +[[package]] +name = "arrow-array" +version = "56.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b02ccba2e977a3aabb4384036109ca32f552399a2bc0588f925f91ed073ce70c" +dependencies = [ + "ahash", + "arrow-buffer 56.2.1", + "arrow-data 56.2.1", + "arrow-schema 56.2.1", + "chrono", + "half", + "hashbrown 0.16.1", + "num", +] + [[package]] name = "arrow-buffer" version = "55.2.0" @@ -132,16 +148,27 @@ dependencies = [ "num", ] +[[package]] +name = "arrow-buffer" +version = "56.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a90f8bece6a9ee316a699fbbfde368a206676a1206ce89b50f07937648e76c3c" +dependencies = [ + "bytes", + "half", + "num", +] + [[package]] name = "arrow-cast" version = "55.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e4f12eccc3e1c05a766cafb31f6a60a46c2f8efec9b74c6e0648766d30686af8" dependencies = [ - "arrow-array", - "arrow-buffer", - "arrow-data", - "arrow-schema", + "arrow-array 55.2.0", + "arrow-buffer 55.2.0", + "arrow-data 55.2.0", + "arrow-schema 55.2.0", "arrow-select", "atoi", "base64 0.22.1", @@ -158,8 +185,20 @@ version = "55.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8de1ce212d803199684b658fc4ba55fb2d7e87b213de5af415308d2fee3619c2" dependencies = [ - "arrow-buffer", - "arrow-schema", + "arrow-buffer 55.2.0", + "arrow-schema 55.2.0", + "half", + "num", +] + +[[package]] +name = "arrow-data" +version = "56.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78468c813909465dd0f858950c8a0614eb63608134acf95c602ec21381258b28" +dependencies = [ + "arrow-buffer 56.2.1", + "arrow-schema 56.2.1", "half", "num", ] @@ -170,10 +209,10 @@ version = "55.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6506e3a059e3be23023f587f79c82ef0bcf6d293587e3272d20f2d30b969b5a7" dependencies = [ - "arrow-array", - "arrow-buffer", - "arrow-data", - "arrow-schema", + "arrow-array 55.2.0", + "arrow-buffer 55.2.0", + "arrow-data 55.2.0", + "arrow-schema 55.2.0", "arrow-select", ] @@ -183,10 +222,10 @@ version = "55.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52bf7393166beaf79b4bed9bfdf19e97472af32ce5b6b48169d321518a08cae2" dependencies = [ - "arrow-array", - "arrow-buffer", - "arrow-data", - "arrow-schema", + "arrow-array 55.2.0", + "arrow-buffer 55.2.0", + "arrow-data 55.2.0", + "arrow-schema 55.2.0", "half", ] @@ -199,6 +238,12 @@ dependencies = [ "bitflags 2.11.1", ] +[[package]] +name = "arrow-schema" +version = "56.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a0d5eb3fe25337ff83e8333a08379bdd1540b0961b1c888f6e505d971c198e1" + [[package]] name = "arrow-select" version = "55.2.0" @@ -206,10 +251,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd2b45757d6a2373faa3352d02ff5b54b098f5e21dccebc45a21806bc34501e5" dependencies = [ "ahash", - "arrow-array", - "arrow-buffer", - "arrow-data", - "arrow-schema", + "arrow-array 55.2.0", + "arrow-buffer 55.2.0", + "arrow-data 55.2.0", + "arrow-schema 55.2.0", "num", ] @@ -219,10 +264,10 @@ version = "55.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0377d532850babb4d927a06294314b316e23311503ed580ec6ce6a0158f49d40" dependencies = [ - "arrow-array", - "arrow-buffer", - "arrow-data", - "arrow-schema", + "arrow-array 55.2.0", + "arrow-buffer 55.2.0", + "arrow-data 55.2.0", + "arrow-schema 55.2.0", "arrow-select", "memchr", "num", @@ -368,7 +413,9 @@ dependencies = [ name = "bugscope" version = "0.15.1" dependencies = [ + "arrow-array 56.2.1", "dirs 5.0.1", + "icebug", "lbug", "serde", "serde_json", @@ -923,7 +970,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -1561,6 +1608,12 @@ dependencies = [ "foldhash 0.1.5", ] +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" + [[package]] name = "hashbrown" version = "0.17.1" @@ -1701,6 +1754,18 @@ dependencies = [ "cc", ] +[[package]] +name = "icebug" +version = "0.1.0" +dependencies = [ + "arrow-array 56.2.1", + "arrow-buffer 56.2.1", + "cc", + "cxx", + "cxx-build", + "pkg-config", +] + [[package]] name = "ico" version = "0.5.0" @@ -4525,7 +4590,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6dec01a..36e996f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -15,5 +15,7 @@ tauri = { version = "2", features = [] } serde = { version = "1", features = ["derive"] } serde_json = "1" lbug = { git = "https://github.com/LadybugDB/ladybug-rust", features = ["arrow"] } +icebug = { path = "../../icebug-rust" } +arrow-array = "56" walkdir = "2" dirs = "5" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index d1dd401..d1dc837 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,5 @@ +use arrow_array::UInt64Array; +use icebug::{GraphR, Leiden}; use lbug::{Connection, Database, SystemConfig, Value}; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; @@ -52,17 +54,37 @@ struct GraphCsr { edge_ids: Option>, } +#[derive(Debug, Clone, Serialize, Deserialize)] +struct GraphCluster { + #[serde(rename = "clusterId")] + cluster_id: u64, + label: String, + size: usize, + #[serde(rename = "parentClusterId", skip_serializing_if = "Option::is_none")] + parent_cluster_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct GraphClusterLevel { + level: usize, + membership: Vec, + clusters: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize)] struct GraphData { nodes: Vec, links: Vec, #[serde(skip_serializing_if = "Option::is_none")] csr: Option, + #[serde(rename = "clusterLevels", skip_serializing_if = "Option::is_none")] + cluster_levels: Option>, } const SEED_NODE_COUNT: usize = 8; const EXPAND_BATCH_SIZE: usize = 8; const EDGE_SCAN_LIMIT: usize = 10_000; +const CLUSTER_LEVEL_LIMIT: usize = 3; const EXPANDER_PREFIX: &str = "__expand__:"; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -243,9 +265,223 @@ fn build_csr(nodes: &[GraphNode], links: &[GraphLink]) -> GraphCsr { } } +fn build_undirected_csr(node_count: usize, links: &[GraphLink], node_index: &HashMap) -> GraphCsr { + let mut outgoing: Vec> = vec![Vec::new(); node_count]; + for link in links { + let Some(&source) = node_index.get(&link.source) else { + continue; + }; + let Some(&target) = node_index.get(&link.target) else { + continue; + }; + if source == target { + continue; + } + outgoing[source].push(target); + outgoing[target].push(source); + } + + let mut indptr = Vec::with_capacity(node_count + 1); + let mut indices = Vec::new(); + for mut neighbors in outgoing { + neighbors.sort_unstable(); + neighbors.dedup(); + indptr.push(indices.len() as u64); + indices.extend(neighbors.into_iter().map(|target| target as u64)); + } + indptr.push(indices.len() as u64); + + GraphCsr { + indptr, + indices, + edge_ids: None, + } +} + +fn leiden_membership(node_count: usize, csr: &GraphCsr) -> Result, String> { + if node_count == 0 { + return Ok(Vec::new()); + } + if csr.indices.is_empty() { + return Ok((0..node_count as u64).collect()); + } + + let graph = GraphR::from_csr( + node_count as u64, + false, + UInt64Array::from(csr.indices.clone()), + UInt64Array::from(csr.indptr.clone()), + ) + .map_err(|e| format!("Failed to create Icebug CSR graph: {e}"))?; + let mut leiden = Leiden::new(&graph, 3, true, 1.0) + .map_err(|e| format!("Failed to create Leiden clustering: {e}"))?; + leiden.run().map_err(|e| format!("Leiden clustering failed: {e}"))?; + let partition = leiden + .partition() + .map_err(|e| format!("Failed to read Leiden partition: {e}"))?; + Ok(partition.membership) +} + +fn remap_membership(membership: &[u64]) -> (Vec, HashMap) { + let mut ids: Vec = membership.to_vec(); + ids.sort_unstable(); + ids.dedup(); + let remap: HashMap = ids + .into_iter() + .enumerate() + .map(|(index, id)| (id, index as u64)) + .collect(); + let mapped = membership + .iter() + .map(|id| *remap.get(id).unwrap_or(&0)) + .collect(); + (mapped, remap) +} + +fn cluster_records(membership: &[u64], parent_membership: Option<&[u64]>) -> Vec { + let mut counts: HashMap = HashMap::new(); + let mut parents: HashMap = HashMap::new(); + for (index, cluster_id) in membership.iter().enumerate() { + *counts.entry(*cluster_id).or_insert(0) += 1; + if let Some(parent_ids) = parent_membership { + if let Some(parent_id) = parent_ids.get(index) { + parents.entry(*cluster_id).or_insert(*parent_id); + } + } + } + + let mut clusters: Vec = counts + .into_iter() + .map(|(cluster_id, size)| GraphCluster { + cluster_id, + label: format!("Cluster {cluster_id}"), + size, + parent_cluster_id: parents.get(&cluster_id).copied(), + }) + .collect(); + clusters.sort_by_key(|cluster| cluster.cluster_id); + clusters +} + +fn aggregate_cluster_edges(membership: &[u64], links: &[GraphLink], node_index: &HashMap) -> (usize, Vec) { + let cluster_count = membership.iter().max().map(|id| *id as usize + 1).unwrap_or(0); + let mut seen = HashSet::new(); + let mut links_out = Vec::new(); + + for link in links { + let Some(&source_index) = node_index.get(&link.source) else { + continue; + }; + let Some(&target_index) = node_index.get(&link.target) else { + continue; + }; + let source = membership[source_index]; + let target = membership[target_index]; + if source == target { + continue; + } + let key = if source < target { (source, target) } else { (target, source) }; + if seen.insert(key) { + links_out.push(GraphLink { + source: key.0.to_string(), + target: key.1.to_string(), + label: "cluster".to_string(), + }); + } + } + + (cluster_count, links_out) +} + +fn compute_cluster_levels(nodes: &[GraphNode], links: &[GraphLink]) -> Option> { + let node_count = nodes.len(); + if node_count < 2 || links.is_empty() { + return None; + } + + let node_index: HashMap = nodes + .iter() + .enumerate() + .map(|(index, node)| (node.id.clone(), index)) + .collect(); + + let csr = build_undirected_csr(node_count, links, &node_index); + let (level_zero, _) = remap_membership(&leiden_membership(node_count, &csr).ok()?); + let mut levels = Vec::new(); + levels.push(GraphClusterLevel { + level: 0, + membership: level_zero.clone(), + clusters: cluster_records(&level_zero, None), + }); + + let mut node_membership = level_zero.clone(); + let mut graph_membership = level_zero; + let mut current_links = links.to_vec(); + let mut current_node_index = node_index; + + for level in 1..CLUSTER_LEVEL_LIMIT { + let (cluster_count, aggregate_links) = aggregate_cluster_edges(&graph_membership, ¤t_links, ¤t_node_index); + if cluster_count < 2 || aggregate_links.is_empty() || cluster_count >= graph_membership.len() { + break; + } + + let cluster_nodes: Vec = (0..cluster_count) + .map(|index| GraphNode { + id: index.to_string(), + name: format!("Cluster {index}"), + label: "Cluster".to_string(), + table_id: None, + rowid: None, + community: Some(index as u64), + expansion_kind: None, + expand_node_id: None, + offset: None, + hidden_count: None, + }) + .collect(); + let cluster_node_index: HashMap = cluster_nodes + .iter() + .enumerate() + .map(|(index, node)| (node.id.clone(), index)) + .collect(); + let cluster_csr = build_undirected_csr(cluster_count, &aggregate_links, &cluster_node_index); + let (cluster_membership, _) = remap_membership(&leiden_membership(cluster_count, &cluster_csr).ok()?); + let next_membership: Vec = node_membership + .iter() + .map(|cluster_id| cluster_membership[*cluster_id as usize]) + .collect(); + + if next_membership == node_membership { + break; + } + + if let Some(previous) = levels.last_mut() { + previous.clusters = cluster_records(&node_membership, Some(&next_membership)); + } + levels.push(GraphClusterLevel { + level, + membership: next_membership.clone(), + clusters: cluster_records(&next_membership, None), + }); + + node_membership = next_membership; + graph_membership = cluster_membership; + current_links = aggregate_links; + current_node_index = cluster_node_index; + } + + Some(levels) +} + fn graph_data(nodes: Vec, links: Vec) -> GraphData { let csr = Some(build_csr(&nodes, &links)); - GraphData { nodes, links, csr } + let cluster_levels = compute_cluster_levels(&nodes, &links); + GraphData { + nodes, + links, + csr, + cluster_levels, + } } fn collect_edge_graph(conn: &Connection, limit: usize) -> Result { diff --git a/src/App.css b/src/App.css index 310ca3d..5e3954f 100644 --- a/src/App.css +++ b/src/App.css @@ -127,6 +127,51 @@ body { gap: 12px; } +.cluster-controls { + display: inline-flex; + align-items: center; + height: 34px; + 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; +} + +.cluster-controls button { + min-width: 76px; + height: 100%; + padding: 0 12px; + background: transparent; + color: var(--text-secondary); + border: 0; + cursor: pointer; + font-size: 14px; +} + +.cluster-controls button.active { + background-color: var(--accent-color); + color: white; +} + +.cluster-controls button:hover:not(.active) { + color: var(--text-primary); +} + .renderer-toggle { display: inline-grid; grid-template-columns: 1fr 1fr; diff --git a/src/App.tsx b/src/App.tsx index 80a56db..4331961 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -37,10 +37,24 @@ interface GraphCsr { edgeIds?: number[] | null } +interface GraphCluster { + clusterId: number + label: string + size: number + parentClusterId?: number | null +} + +interface GraphClusterLevel { + level: number + membership: number[] + clusters: GraphCluster[] +} + interface GraphData { nodes: GraphNode[] links: GraphLink[] csr?: GraphCsr + clusterLevels?: GraphClusterLevel[] } interface NormalizedGraphLink { @@ -53,6 +67,7 @@ interface NormalizedGraphData { nodes: GraphNode[] links: NormalizedGraphLink[] csr?: GraphCsr + clusterLevels?: GraphClusterLevel[] } interface ForceGraphLink { @@ -123,6 +138,7 @@ function normalizeGraphData(graphData: GraphData): NormalizedGraphData { label: link.label, })), csr: graphData.csr, + clusterLevels: graphData.clusterLevels, } } @@ -153,6 +169,89 @@ function mergeGraphData(current: GraphData, incoming: GraphData, expandedNodeId? } } +function buildCommunityClusterLevels(graphData: NormalizedGraphData): GraphClusterLevel[] { + if (graphData.clusterLevels?.length) return graphData.clusterLevels + + const communityIds = graphData.nodes.map(node => node.community) + if (communityIds.some(id => id === undefined)) return [] + + const counts = new Map() + communityIds.forEach(id => { + if (id !== undefined) counts.set(id, (counts.get(id) || 0) + 1) + }) + + return [{ + level: 0, + membership: communityIds.map(id => id ?? 0), + clusters: [...counts.entries()].map(([clusterId, size]) => ({ + clusterId, + label: `Community ${clusterId}`, + size, + })), + }] +} + +function collapseGraphByClusterLevel(graphData: NormalizedGraphData, level: GraphClusterLevel): NormalizedGraphData { + const clusterById = new Map(level.clusters.map(cluster => [cluster.clusterId, cluster])) + const clusterCounts = new Map() + + level.membership.forEach(clusterId => { + clusterCounts.set(clusterId, (clusterCounts.get(clusterId) || 0) + 1) + }) + + const nodes: GraphNode[] = [...clusterCounts.entries()] + .sort(([a], [b]) => a - b) + .map(([clusterId, size]) => { + const cluster = clusterById.get(clusterId) + return { + id: `__cluster__:${level.level}:${clusterId}`, + name: cluster?.label || `Cluster ${clusterId}`, + label: `Cluster L${level.level}`, + community: clusterId, + expansionKind: 'cluster', + hiddenCount: cluster?.size ?? size, + } + }) + + const clusterNodeId = new Map(nodes.map(node => [node.community ?? 0, node.id])) + const edgeCounts = new Map; count: number }>() + const nodeIndex = new Map(graphData.nodes.map((node, index) => [node.id, index])) + + graphData.links.forEach(link => { + const sourceIndex = nodeIndex.get(link.source) + const targetIndex = nodeIndex.get(link.target) + if (sourceIndex === undefined || targetIndex === undefined) return + + const sourceCluster = level.membership[sourceIndex] + const targetCluster = level.membership[targetIndex] + if (sourceCluster === targetCluster) return + + const source = clusterNodeId.get(sourceCluster) + const target = clusterNodeId.get(targetCluster) + if (!source || !target) return + + const key = `${source}\t${target}` + const record = edgeCounts.get(key) || { source, target, labels: new Map(), count: 0 } + record.count += 1 + record.labels.set(link.label, (record.labels.get(link.label) || 0) + 1) + 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 } + }) + + return { + nodes, + links, + csr: buildGraphCsr({ nodes, links }), + clusterLevels: graphData.clusterLevels, + } +} + function buildGraphCsr(graphData: NormalizedGraphData): GraphCsr { if ( graphData.csr && @@ -524,6 +623,8 @@ 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 [lastExpandedNodeIds, setLastExpandedNodeIds] = useState>(() => new Set()) // eslint-disable-next-line @typescript-eslint/no-explicit-any const graphRef = useRef(null) @@ -705,20 +806,43 @@ function App() { }, [graphData, selectedId]) 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 visibleGraphData = useMemo(() => ( + clusterCollapsed && selectedCluster + ? collapseGraphByClusterLevel(normalizedGraphData, selectedCluster) + : normalizedGraphData + ), [clusterCollapsed, normalizedGraphData, selectedCluster]) + + /* eslint-disable react-hooks/set-state-in-effect */ + useEffect(() => { + if (!selectedCluster) { + setClusterCollapsed(false) + setSelectedClusterLevel(0) + return + } + if (!clusterLevels.some(level => level.level === selectedClusterLevel)) { + setSelectedClusterLevel(selectedCluster.level) + } + }, [clusterLevels, selectedCluster, selectedClusterLevel]) + /* eslint-enable react-hooks/set-state-in-effect */ + const forceGraphData = useMemo>(() => ({ - nodes: normalizedGraphData.nodes.map(node => ({ ...node })), - links: normalizedGraphData.links.map(link => ({ ...link })), - }), [normalizedGraphData]) + nodes: visibleGraphData.nodes.map(node => ({ ...node })), + links: visibleGraphData.links.map(link => ({ ...link })), + }), [visibleGraphData]) const nodeDegree = useMemo(() => { const degrees: Record = {} - normalizedGraphData.nodes.forEach(n => degrees[n.id] = 0) - normalizedGraphData.links.forEach(link => { + visibleGraphData.nodes.forEach(n => degrees[n.id] = 0) + visibleGraphData.links.forEach(link => { degrees[link.source] = (degrees[link.source] || 0) + 1 degrees[link.target] = (degrees[link.target] || 0) + 1 }) return degrees - }, [normalizedGraphData]) + }, [visibleGraphData]) const maxDegree = useMemo(() => Math.max(1, ...Object.values(nodeDegree)), [nodeDegree]) @@ -726,14 +850,14 @@ function App() { return new Set( [ ...lastExpandedNodeIds, - ...[...normalizedGraphData.nodes] + ...[...visibleGraphData.nodes] .sort((a, b) => (nodeDegree[b.id] || 0) - (nodeDegree[a.id] || 0)) .filter(node => !isExpanderNode(node)) .slice(0, 5) .map(node => node.id), ] ) - }, [lastExpandedNodeIds, normalizedGraphData.nodes, nodeDegree]) + }, [lastExpandedNodeIds, visibleGraphData.nodes, nodeDegree]) const getNodeColor = useCallback((node: GraphNode) => { const key = node.community === undefined ? node.label : `community:${node.community}` @@ -754,9 +878,18 @@ function App() { const getNodeSize = useCallback((node: GraphNode) => { const degree = nodeDegree[node.id] || 0 - return 4 + (degree / maxDegree) * 12 + const clusterBoost = node.expansionKind === 'cluster' ? Math.min(10, Math.sqrt(node.hiddenCount || 1)) : 0 + return 4 + clusterBoost + (degree / maxDegree) * 12 }, [nodeDegree, maxDegree]) + const handleVisibleNodeClick = useCallback((nodeId: string) => { + if (nodeId.startsWith('__cluster__:')) { + setClusterCollapsed(false) + return + } + handleNodeClick(nodeId) + }, [handleNodeClick]) + // eslint-disable-next-line @typescript-eslint/no-explicit-any const paintNode = useCallback((node: any, ctx: CanvasRenderingContext2D) => { const size = getNodeSize(node) @@ -839,12 +972,36 @@ function App() {
- {loading ? 'Loading...' : `${graphData.nodes.length} nodes, ${graphData.links.length} edges`} + {loading + ? 'Loading...' + : clusterCollapsed && selectedCluster + ? `${visibleGraphData.nodes.length} clusters, ${visibleGraphData.links.length} aggregate edges` + : `${graphData.nodes.length} nodes, ${graphData.links.length} edges`} {error && {error}}
+ {clusterLevels.length > 0 && ( +
+ + +
+ )}
From 2bd1240dcaaefbb038ca81f3a53ac883c7af41d5 Mon Sep 17 00:00:00 2001 From: Arun Sharma Date: Mon, 25 May 2026 14:12:22 -0700 Subject: [PATCH 06/10] Enable analytics for dev script --- package.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/package.json b/package.json index b668826..a779a6c 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,8 @@ "dev:frontend": "vite", "build:frontend": "tsc -b && vite build", "tauri": "cargo tauri", - "dev": "cargo tauri dev", + "dev": "cargo tauri dev --features=icebug-analytics", + "dev:no-analytics": "cargo tauri dev", "build": "cargo tauri build", "lint": "eslint .", "preview": "vite preview" From c4cb651c054c33fad3e87f6cab2b55f795c93a9e Mon Sep 17 00:00:00 2001 From: Arun Sharma Date: Mon, 25 May 2026 14:21:09 -0700 Subject: [PATCH 07/10] Expand clicked clusters in place --- src/App.tsx | 81 ++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 64 insertions(+), 17 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 832bbbc..67b0687 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -206,20 +206,39 @@ function buildCommunityClusterLevels(graphData: NormalizedGraphData): GraphClust }] } -function collapseGraphByClusterLevel(graphData: NormalizedGraphData, level: GraphClusterLevel): NormalizedGraphData { +function getClusterNodeId(level: number, clusterId: number) { + return `__cluster__:${level}:${clusterId}` +} + +function parseClusterNodeId(nodeId: string): { level: number; clusterId: number } | null { + const match = /^__cluster__:(\d+):(\d+)$/.exec(nodeId) + if (!match) return null + return { + level: Number(match[1]), + clusterId: Number(match[2]), + } +} + +function collapseGraphByClusterLevel( + graphData: NormalizedGraphData, + level: GraphClusterLevel, + expandedClusterId: number | null = null, +): NormalizedGraphData { const clusterById = new Map(level.clusters.map(cluster => [cluster.clusterId, cluster])) const clusterCounts = new Map() + const nodeIndex = new Map(graphData.nodes.map((node, index) => [node.id, index])) level.membership.forEach(clusterId => { clusterCounts.set(clusterId, (clusterCounts.get(clusterId) || 0) + 1) }) - const nodes: GraphNode[] = [...clusterCounts.entries()] + const collapsedNodes: GraphNode[] = [...clusterCounts.entries()] + .filter(([clusterId]) => clusterId !== expandedClusterId) .sort(([a], [b]) => a - b) .map(([clusterId, size]) => { const cluster = clusterById.get(clusterId) return { - id: `__cluster__:${level.level}:${clusterId}`, + id: getClusterNodeId(level.level, clusterId), name: cluster?.label || `Cluster ${clusterId}`, label: `Cluster L${level.level}`, community: clusterId, @@ -228,9 +247,12 @@ function collapseGraphByClusterLevel(graphData: NormalizedGraphData, level: Grap } }) - const clusterNodeId = new Map(nodes.map(node => [node.community ?? 0, node.id])) + const expandedNodes = expandedClusterId === null + ? [] + : graphData.nodes.filter((_, index) => level.membership[index] === expandedClusterId) + const nodes = [...collapsedNodes, ...expandedNodes] + const visibleNodeIds = new Set(nodes.map(node => node.id)) const edgeCounts = new Map; count: number }>() - const nodeIndex = new Map(graphData.nodes.map((node, index) => [node.id, index])) graphData.links.forEach(link => { const sourceIndex = nodeIndex.get(link.source) @@ -239,11 +261,14 @@ function collapseGraphByClusterLevel(graphData: NormalizedGraphData, level: Grap const sourceCluster = level.membership[sourceIndex] const targetCluster = level.membership[targetIndex] - if (sourceCluster === targetCluster) return - const source = clusterNodeId.get(sourceCluster) - const target = clusterNodeId.get(targetCluster) - if (!source || !target) return + const source = sourceCluster === expandedClusterId + ? link.source + : getClusterNodeId(level.level, sourceCluster) + const target = targetCluster === expandedClusterId + ? link.target + : getClusterNodeId(level.level, targetCluster) + if (source === target || !visibleNodeIds.has(source) || !visibleNodeIds.has(target)) return const key = `${source}\t${target}` const record = edgeCounts.get(key) || { source, target, labels: new Map(), count: 0 } @@ -641,6 +666,7 @@ function App() { const [renderer, setRenderer] = useState<'sigma' | 'force'>('sigma') const [clusterCollapsed, setClusterCollapsed] = useState(false) const [selectedClusterLevel, setSelectedClusterLevel] = useState(0) + const [expandedClusterId, setExpandedClusterId] = useState(null) const [lastExpandedNodeIds, setLastExpandedNodeIds] = useState>(() => new Set()) // eslint-disable-next-line @typescript-eslint/no-explicit-any const graphRef = useRef(null) @@ -729,6 +755,7 @@ function App() { console.info('Graph cluster debug:', data.clusterDebug) setGraphData(data) setLastExpandedNodeIds(new Set()) + setExpandedClusterId(null) setLoading(false) setTimeout(() => { if (graphRef.current) { @@ -746,6 +773,7 @@ function App() { console.info('Graph cluster debug:', data.clusterDebug) setGraphData(data) setLastExpandedNodeIds(new Set()) + setExpandedClusterId(null) setLoading(false) setTimeout(() => { if (graphRef.current) { @@ -815,6 +843,7 @@ function App() { }) setGraphData(merged) setLastExpandedNodeIds(highlightedNodeIds) + setExpandedClusterId(null) setLoading(false) }) .catch(err => { @@ -832,21 +861,28 @@ function App() { 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 visibleGraphData = useMemo(() => ( clusterCollapsed && selectedCluster - ? collapseGraphByClusterLevel(normalizedGraphData, selectedCluster) + ? collapseGraphByClusterLevel(normalizedGraphData, selectedCluster, expandedClusterId) : normalizedGraphData - ), [clusterCollapsed, normalizedGraphData, selectedCluster]) + ), [clusterCollapsed, expandedClusterId, normalizedGraphData, selectedCluster]) /* eslint-disable react-hooks/set-state-in-effect */ useEffect(() => { if (!selectedCluster) { setClusterCollapsed(false) setSelectedClusterLevel(0) + setExpandedClusterId(null) return } if (!clusterLevels.some(level => level.level === selectedClusterLevel)) { setSelectedClusterLevel(selectedCluster.level) + setExpandedClusterId(null) } }, [clusterLevels, selectedCluster, selectedClusterLevel]) /* eslint-enable react-hooks/set-state-in-effect */ @@ -905,8 +941,11 @@ function App() { }, [nodeDegree, maxDegree]) const handleVisibleNodeClick = useCallback((nodeId: string) => { - if (nodeId.startsWith('__cluster__:')) { - setClusterCollapsed(false) + const clusterNode = parseClusterNodeId(nodeId) + if (clusterNode) { + setSelectedClusterLevel(clusterNode.level) + setExpandedClusterId(clusterNode.clusterId) + setClusterCollapsed(true) return } handleNodeClick(nodeId) @@ -997,7 +1036,9 @@ function App() { {loading ? 'Loading...' : clusterCollapsed && selectedCluster - ? `${visibleGraphData.nodes.length} clusters, ${visibleGraphData.links.length} aggregate edges` + ? expandedCluster + ? `${expandedCluster.label} expanded, ${visibleGraphData.nodes.length} visible nodes, ${visibleGraphData.links.length} visible edges` + : `${visibleGraphData.nodes.length} clusters, ${visibleGraphData.links.length} aggregate edges` : `${graphData.nodes.length} nodes, ${graphData.links.length} edges`} {!loading && ( @@ -1016,7 +1057,10 @@ function App() {
)} From 9969794b2b95a1e8bb45659362c5cb4f2c696e50 Mon Sep 17 00:00:00 2001 From: Arun Sharma Date: Mon, 25 May 2026 14:28:42 -0700 Subject: [PATCH 08/10] Keep cluster state after expansion --- src-tauri/src/lib.rs | 13 +++++++++++-- src/App.css | 30 ------------------------------ src/App.tsx | 42 +++--------------------------------------- 3 files changed, 14 insertions(+), 71 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 0830491..34bfece 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -821,11 +821,12 @@ fn expand_node_from_full( visible_node_ids: &[String], offset: usize, ) -> GraphData { - let visible_ids: HashSet = visible_node_ids + let visible_order: Vec = visible_node_ids .iter() .filter(|id| !id.starts_with(EXPANDER_PREFIX)) .cloned() .collect(); + let visible_ids: HashSet = visible_order.iter().cloned().collect(); let mut degrees: HashMap = HashMap::new(); for link in &full_graph.links { @@ -866,7 +867,15 @@ fn expand_node_from_full( let mut return_ids = visible_ids.clone(); return_ids.extend(selected_ids.iter().cloned()); - let mut nodes: Vec = selected_ids + let mut node_ids = visible_order; + for id in &selected_ids { + if node_ids.contains(id) { + continue; + } + node_ids.push(id.clone()); + } + + let mut nodes: Vec = node_ids .iter() .filter_map(|id| full_node_by_id.get(id).cloned()) .collect(); diff --git a/src/App.css b/src/App.css index 45b81c7..cb451cd 100644 --- a/src/App.css +++ b/src/App.css @@ -173,36 +173,6 @@ body { color: var(--text-primary); } -.cluster-debug { - max-width: min(560px, 44vw); - padding: 5px 8px; - border: 1px solid var(--border-color); - border-radius: 6px; - color: var(--text-secondary); - background-color: var(--bg-tertiary); - font-size: 12px; - line-height: 1.25; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.cluster-debug-ready { - color: #86efac; - border-color: rgba(34, 197, 94, 0.45); -} - -.cluster-debug-error { - color: #fca5a5; - border-color: rgba(239, 68, 68, 0.5); -} - -.cluster-debug-skipped, -.cluster-debug-disabled { - color: #facc15; - border-color: rgba(250, 204, 21, 0.45); -} - .renderer-toggle { display: inline-grid; grid-template-columns: 1fr 1fr; diff --git a/src/App.tsx b/src/App.tsx index 67b0687..6dc2ce5 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -162,28 +162,6 @@ function isExpanderNode(node: GraphNode) { return Boolean(node.expansionKind) || node.id.startsWith(EXPANDER_PREFIX) } -function mergeGraphData(current: GraphData, incoming: GraphData, expandedNodeId?: string): GraphData { - const nodesById = new Map() - current.nodes - .filter(node => node.id !== expandedNodeId) - .forEach(node => nodesById.set(node.id, { ...node })) - incoming.nodes.forEach(node => nodesById.set(node.id, { ...node })) - - const linksByKey = new Map() - current.links - .filter(link => getEndpointId(link.source) !== expandedNodeId && getEndpointId(link.target) !== expandedNodeId) - .forEach(link => linksByKey.set(`${getEndpointId(link.source)}\t${getEndpointId(link.target)}\t${link.label}`, { ...link })) - incoming.links.forEach(link => { - linksByKey.set(`${getEndpointId(link.source)}\t${getEndpointId(link.target)}\t${link.label}`, { ...link }) - }) - - return { - nodes: [...nodesById.values()], - links: [...linksByKey.values()], - clusterDebug: incoming.clusterDebug || current.clusterDebug, - } -} - function buildCommunityClusterLevels(graphData: NormalizedGraphData): GraphClusterLevel[] { if (graphData.clusterLevels?.length) return graphData.clusterLevels @@ -834,14 +812,12 @@ function App() { }) .then(data => { const beforeNodeIds = realNodeIds(graphData.nodes) - const returnedNodeIds = realNodeIds(data.nodes) - const merged = mergeGraphData(graphData, data, node.id) - const highlightedNodeIds = realNodeIds(merged.nodes) + const highlightedNodeIds = realNodeIds(data.nodes) beforeNodeIds.forEach(id => { - if (!returnedNodeIds.has(id)) highlightedNodeIds.delete(id) + highlightedNodeIds.delete(id) }) - setGraphData(merged) + setGraphData(data) setLastExpandedNodeIds(highlightedNodeIds) setExpandedClusterId(null) setLoading(false) @@ -854,10 +830,6 @@ function App() { const normalizedGraphData = useMemo(() => normalizeGraphData(graphData), [graphData]) const clusterLevels = useMemo(() => buildCommunityClusterLevels(normalizedGraphData), [normalizedGraphData]) - const clusterDebug = normalizedGraphData.clusterDebug - const clusterDebugText = clusterDebug - ? clusterDebug.message - : 'Cluster status unavailable: backend did not return cluster diagnostics.' const selectedCluster = useMemo(() => ( clusterLevels.find(level => level.level === selectedClusterLevel) || clusterLevels[0] ), [clusterLevels, selectedClusterLevel]) @@ -1041,14 +1013,6 @@ function App() { : `${visibleGraphData.nodes.length} clusters, ${visibleGraphData.links.length} aggregate edges` : `${graphData.nodes.length} nodes, ${graphData.links.length} edges`} - {!loading && ( - - {clusterDebugText} - - )} {error && {error}}
From 68310a9fb163fff0fac7b742c5f33657542b7856 Mon Sep 17 00:00:00 2001 From: Arun Sharma Date: Mon, 25 May 2026 14:37:56 -0700 Subject: [PATCH 09/10] Keep expanders visible in cluster view --- src/App.tsx | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 6dc2ce5..2c3b342 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -206,7 +206,8 @@ function collapseGraphByClusterLevel( const clusterCounts = new Map() const nodeIndex = new Map(graphData.nodes.map((node, index) => [node.id, index])) - level.membership.forEach(clusterId => { + level.membership.forEach((clusterId, index) => { + if (isExpanderNode(graphData.nodes[index])) return clusterCounts.set(clusterId, (clusterCounts.get(clusterId) || 0) + 1) }) @@ -227,11 +228,20 @@ function collapseGraphByClusterLevel( const expandedNodes = expandedClusterId === null ? [] - : graphData.nodes.filter((_, index) => level.membership[index] === expandedClusterId) - const nodes = [...collapsedNodes, ...expandedNodes] + : graphData.nodes.filter((node, index) => ( + !isExpanderNode(node) && level.membership[index] === expandedClusterId + )) + 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) + } + graphData.links.forEach(link => { const sourceIndex = nodeIndex.get(link.source) const targetIndex = nodeIndex.get(link.target) @@ -240,12 +250,8 @@ function collapseGraphByClusterLevel( const sourceCluster = level.membership[sourceIndex] const targetCluster = level.membership[targetIndex] - const source = sourceCluster === expandedClusterId - ? link.source - : getClusterNodeId(level.level, sourceCluster) - const target = targetCluster === expandedClusterId - ? link.target - : getClusterNodeId(level.level, targetCluster) + 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 key = `${source}\t${target}` From dd0a8bd4bf213c9f0999045538f2dc370fb4bbdb Mon Sep 17 00:00:00 2001 From: Arun Sharma Date: Mon, 25 May 2026 14:40:08 -0700 Subject: [PATCH 10/10] Color clusters separately from expanders --- src/App.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 2c3b342..e4995c3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -159,7 +159,11 @@ function normalizeGraphData(graphData: GraphData): NormalizedGraphData { const EXPANDER_PREFIX = '__expand__:' function isExpanderNode(node: GraphNode) { - return Boolean(node.expansionKind) || node.id.startsWith(EXPANDER_PREFIX) + return node.expansionKind === 'node' || node.id.startsWith(EXPANDER_PREFIX) +} + +function isClusterNode(node: GraphNode) { + return node.expansionKind === 'cluster' } function buildCommunityClusterLevels(graphData: NormalizedGraphData): GraphClusterLevel[] { @@ -313,7 +317,7 @@ function buildGraphCsr(graphData: NormalizedGraphData): GraphCsr { } function realNodeIds(nodes: GraphNode[]): Set { - return new Set(nodes.filter(node => !isExpanderNode(node)).map(node => node.id)) + return new Set(nodes.filter(node => !isExpanderNode(node) && !isClusterNode(node)).map(node => node.id)) } function drawRoundedRect(