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..a779a6c 100644 --- a/package.json +++ b/package.json @@ -8,13 +8,13 @@ "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" }, "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..59ca556 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,208 @@ 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 55.2.0", + "arrow-buffer 55.2.0", + "arrow-cast", + "arrow-data 55.2.0", + "arrow-ord", + "arrow-row", + "arrow-schema 55.2.0", + "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 55.2.0", + "arrow-buffer 55.2.0", + "arrow-data 55.2.0", + "arrow-schema 55.2.0", + "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 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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "169b1d5d6cb390dd92ce582b06b23815c7953e9dfaaea75556e89d890d19993d" +dependencies = [ + "bytes", + "half", + "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 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", + "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 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", +] + +[[package]] +name = "arrow-ord" +version = "55.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6506e3a059e3be23023f587f79c82ef0bcf6d293587e3272d20f2d30b969b5a7" +dependencies = [ + "arrow-array 55.2.0", + "arrow-buffer 55.2.0", + "arrow-data 55.2.0", + "arrow-schema 55.2.0", + "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 55.2.0", + "arrow-buffer 55.2.0", + "arrow-data 55.2.0", + "arrow-schema 55.2.0", + "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-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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd2b45757d6a2373faa3352d02ff5b54b098f5e21dccebc45a21806bc34501e5" +dependencies = [ + "ahash", + "arrow-array 55.2.0", + "arrow-buffer 55.2.0", + "arrow-data 55.2.0", + "arrow-schema 55.2.0", + "num", +] + +[[package]] +name = "arrow-string" +version = "55.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0377d532850babb4d927a06294314b316e23311503ed580ec6ce6a0158f49d40" +dependencies = [ + "arrow-array 55.2.0", + "arrow-buffer 55.2.0", + "arrow-data 55.2.0", + "arrow-schema 55.2.0", + "arrow-select", + "memchr", + "num", + "regex", + "regex-syntax", +] + [[package]] name = "atk" version = "0.18.2" @@ -82,6 +298,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" @@ -188,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", @@ -401,6 +628,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 +731,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" @@ -1328,6 +1581,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" @@ -1343,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" @@ -1483,6 +1754,19 @@ dependencies = [ "cc", ] +[[package]] +name = "icebug" +version = "0.1.0" +source = "git+https://github.com/Ladybug-Memory/icebug-rust#18dbd496d6bc85e5cfff952c73841401cb834134" +dependencies = [ + "arrow-array 56.2.1", + "arrow-buffer 56.2.1", + "cc", + "cxx", + "cxx-build", + "pkg-config", +] + [[package]] name = "ico" version = "0.5.0" @@ -1767,8 +2051,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 +2070,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 +2176,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 +2325,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 +2402,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" dependencies = [ "autocfg", + "libm", ] [[package]] @@ -2653,6 +3066,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 +3891,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" @@ -4780,6 +5208,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..172471a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -10,10 +10,16 @@ crate-type = ["staticlib", "cdylib", "rlib"] [build-dependencies] tauri-build = { version = "2", features = [] } +[features] +default = [] +icebug-analytics = ["dep:arrow-array", "dep:icebug"] + [dependencies] 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"] } +icebug = { git = "https://github.com/Ladybug-Memory/icebug-rust", optional = true } +arrow-array = { version = "56", optional = true } walkdir = "2" dirs = "5" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index aeb1055..34bfece 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,3 +1,7 @@ +#[cfg(feature = "icebug-analytics")] +use arrow_array::UInt64Array; +#[cfg(feature = "icebug-analytics")] +use icebug::{GraphR, Leiden}; use lbug::{Connection, Database, SystemConfig, Value}; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; @@ -21,6 +25,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,15 +48,63 @@ 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 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 GraphClusterDebug { + enabled: bool, + status: String, + message: String, + #[serde(rename = "nodeCount")] + node_count: usize, + #[serde(rename = "edgeCount")] + edge_count: usize, + #[serde(rename = "undirectedEdgeCount")] + undirected_edge_count: usize, + levels: usize, + clusters: usize, +} + #[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>, + #[serde(rename = "clusterDebug")] + cluster_debug: GraphClusterDebug, } const SEED_NODE_COUNT: usize = 8; const EXPAND_BATCH_SIZE: usize = 8; const EDGE_SCAN_LIMIT: usize = 10_000; +#[cfg(feature = "icebug-analytics")] +const CLUSTER_LEVEL_LIMIT: usize = 3; const EXPANDER_PREFIX: &str = "__expand__:"; #[derive(Debug, Clone, Serialize, Deserialize)] @@ -168,6 +226,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), @@ -179,13 +240,442 @@ fn merge_node(nodes: &mut HashMap, node: GraphNode) { nodes.entry(node.id.clone()).or_insert(node); } -fn merge_link(links: &mut Vec, seen: &mut HashSet<(String, String, String)>, link: GraphLink) { +fn merge_link( + links: &mut Vec, + seen: &mut HashSet<(String, String, String)>, + link: GraphLink, +) { let key = (link.source.clone(), link.target.clone(), link.label.clone()); if seen.insert(key) { links.push(link); } } +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), + } +} + +#[cfg(feature = "icebug-analytics")] +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, + } +} + +#[cfg(feature = "icebug-analytics")] +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) +} + +#[cfg(feature = "icebug-analytics")] +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) +} + +#[cfg(feature = "icebug-analytics")] +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 +} + +#[cfg(feature = "icebug-analytics")] +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 cluster_debug( + enabled: bool, + status: &str, + message: String, + node_count: usize, + edge_count: usize, + undirected_edge_count: usize, + levels: usize, + clusters: usize, +) -> GraphClusterDebug { + GraphClusterDebug { + enabled, + status: status.to_string(), + message, + node_count, + edge_count, + undirected_edge_count, + levels, + clusters, + } +} + +#[cfg(feature = "icebug-analytics")] +fn compute_cluster_levels( + nodes: &[GraphNode], + links: &[GraphLink], +) -> (Option>, GraphClusterDebug) { + let node_count = nodes.len(); + if node_count < 2 { + return ( + None, + cluster_debug( + true, + "skipped", + "Leiden skipped: fewer than two visible nodes.".to_string(), + node_count, + links.len(), + 0, + 0, + 0, + ), + ); + } + if links.is_empty() { + return ( + None, + cluster_debug( + true, + "skipped", + "Leiden skipped: the visible graph has no relationships.".to_string(), + node_count, + links.len(), + 0, + 0, + 0, + ), + ); + } + + 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 undirected_edge_count = csr.indices.len() / 2; + if csr.indices.is_empty() { + return ( + None, + cluster_debug( + true, + "skipped", + "Leiden skipped: relationships did not connect visible nodes after filtering." + .to_string(), + node_count, + links.len(), + undirected_edge_count, + 0, + 0, + ), + ); + } + + let level_zero_raw = match leiden_membership(node_count, &csr) { + Ok(membership) => membership, + Err(err) => { + return ( + None, + cluster_debug( + true, + "error", + format!("Leiden failed: {err}"), + node_count, + links.len(), + undirected_edge_count, + 0, + 0, + ), + ); + } + }; + let (level_zero, _) = remap_membership(&level_zero_raw); + 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_raw = match leiden_membership(cluster_count, &cluster_csr) { + Ok(membership) => membership, + Err(err) => { + eprintln!("Stopping hierarchical Leiden at level {level}: {err}"); + break; + } + }; + let (cluster_membership, _) = remap_membership(&cluster_membership_raw); + 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; + } + + let level_count = levels.len(); + let cluster_count = levels + .first() + .map(|level| level.clusters.len()) + .unwrap_or_default(); + let message = format!( + "Leiden produced {level_count} level(s), with {cluster_count} cluster(s) at level 0 from {node_count} visible node(s), {edge_count} directed edge(s), {undirected_edge_count} undirected edge(s).", + edge_count = links.len(), + ); + + ( + Some(levels), + cluster_debug( + true, + "ready", + message, + node_count, + links.len(), + undirected_edge_count, + level_count, + cluster_count, + ), + ) +} + +#[cfg(not(feature = "icebug-analytics"))] +fn compute_cluster_levels( + _nodes: &[GraphNode], + links: &[GraphLink], +) -> (Option>, GraphClusterDebug) { + ( + None, + cluster_debug( + false, + "disabled", + "Leiden disabled: run with `cargo tauri dev --features icebug-analytics`.".to_string(), + _nodes.len(), + links.len(), + 0, + 0, + 0, + ), + ) +} + +fn graph_data(nodes: Vec, links: Vec) -> GraphData { + let csr = Some(build_csr(&nodes, &links)); + let (cluster_levels, cluster_debug) = compute_cluster_levels(&nodes, &links); + eprintln!("Cluster debug: {}", cluster_debug.message); + GraphData { + nodes, + links, + csr, + cluster_levels, + cluster_debug, + } +} + 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 +713,9 @@ fn collect_edge_graph(conn: &Connection, limit: usize) -> Result Result, nodes: &mut Vec, links: &mut Vec) { +fn add_expanders( + graph: &GraphData, + visible_ids: &HashSet, + nodes: &mut Vec, + links: &mut Vec, +) { let known_ids: HashSet = graph.nodes.iter().map(|node| node.id.clone()).collect(); let mut neighbors: HashMap> = HashMap::new(); for link in &graph.links { @@ -267,7 +762,9 @@ fn add_expanders(graph: &GraphData, visible_ids: &HashSet, nodes: &mut V .map(|items| { items .iter() - .filter(|neighbor_id| !visible_ids.contains(*neighbor_id) && known_ids.contains(*neighbor_id)) + .filter(|neighbor_id| { + !visible_ids.contains(*neighbor_id) && known_ids.contains(*neighbor_id) + }) .count() }) .unwrap_or(0); @@ -315,15 +812,21 @@ 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 { - let visible_ids: HashSet = visible_node_ids +fn expand_node_from_full( + full_graph: GraphData, + node_id: &str, + visible_node_ids: &[String], + offset: usize, +) -> GraphData { + 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 { @@ -364,7 +867,15 @@ fn expand_node_from_full(full_graph: GraphData, node_id: &str, visible_node_ids: 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(); @@ -387,7 +898,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 +1077,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,120 +138,186 @@ interface SigmaEdgeLabelNodeData { size: number } -class SigmaGraph< - N extends Record = Record, - E extends Record = Record, -> { - private nodeAttributes = new Map() - private edgeRecords = new Map() +function getEndpointId(endpoint: string | NodeObject): string { + return typeof endpoint === 'object' ? String(endpoint.id) : endpoint +} - get order() { - return this.nodeAttributes.size +function normalizeGraphData(graphData: GraphData): NormalizedGraphData { + return { + nodes: graphData.nodes.map(node => ({ ...node })), + links: graphData.links.map(link => ({ + source: getEndpointId(link.source), + target: getEndpointId(link.target), + label: link.label, + })), + csr: graphData.csr, + clusterLevels: graphData.clusterLevels, + clusterDebug: graphData.clusterDebug, } +} - 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) - } +const EXPANDER_PREFIX = '__expand__:' - 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 }) - } +function isExpanderNode(node: GraphNode) { + return node.expansionKind === 'node' || node.id.startsWith(EXPANDER_PREFIX) +} - hasNode(key: string): boolean { - return this.nodeAttributes.has(key) - } +function isClusterNode(node: GraphNode) { + return node.expansionKind === 'cluster' +} - hasEdge(key: string): boolean { - return this.edgeRecords.has(key) - } +function buildCommunityClusterLevels(graphData: NormalizedGraphData): GraphClusterLevel[] { + if (graphData.clusterLevels?.length) return graphData.clusterLevels - nodes(): string[] { - return [...this.nodeAttributes.keys()] - } + const communityIds = graphData.nodes.map(node => node.community) + if (communityIds.some(id => id === undefined)) return [] - edges(): string[] { - return [...this.edgeRecords.keys()] - } + const counts = new Map() + communityIds.forEach(id => { + if (id !== undefined) counts.set(id, (counts.get(id) || 0) + 1) + }) - forEachNode(callback: (key: string, attributes: N) => void): void { - this.nodeAttributes.forEach((attributes, key) => callback(key, attributes)) - } + return [{ + level: 0, + membership: communityIds.map(id => id ?? 0), + clusters: [...counts.entries()].map(([clusterId, size]) => ({ + clusterId, + label: `Community ${clusterId}`, + size, + })), + }] +} - forEachEdge(callback: (key: string, attributes: E) => void): void { - this.edgeRecords.forEach(({ attributes }, key) => callback(key, attributes)) - } +function getClusterNodeId(level: number, clusterId: number) { + return `__cluster__:${level}:${clusterId}` +} - getNodeAttributes(key: string): N { - const attributes = this.nodeAttributes.get(key) - if (!attributes) throw new Error(`SigmaGraph: node "${key}" not found.`) - return attributes +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]), } +} - getEdgeAttributes(key: string): E { - const record = this.edgeRecords.get(key) - if (!record) throw new Error(`SigmaGraph: edge "${key}" not found.`) - return record.attributes - } +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, index) => { + if (isExpanderNode(graphData.nodes[index])) return + clusterCounts.set(clusterId, (clusterCounts.get(clusterId) || 0) + 1) + }) - 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] - } + 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, + } + }) - on(): void {} + const expandedNodes = expandedClusterId === null + ? [] + : 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) + } - removeListener(): void {} -} + 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] + + 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}` + 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) + }) -function getEndpointId(endpoint: string | NodeObject): string { - return typeof endpoint === 'object' ? String(endpoint.id) : endpoint -} + 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 } + }) -function normalizeGraphData(graphData: GraphData): NormalizedGraphData { return { - nodes: graphData.nodes.map(node => ({ ...node })), - links: graphData.links.map(link => ({ - source: getEndpointId(link.source), - target: getEndpointId(link.target), - label: link.label, - })), + nodes, + links, + csr: buildGraphCsr({ nodes, links }), + clusterLevels: graphData.clusterLevels, + clusterDebug: graphData.clusterDebug, } } -const EXPANDER_PREFIX = '__expand__:' +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 + } -function isExpanderNode(node: GraphNode) { - return Boolean(node.expansionKind) || node.id.startsWith(EXPANDER_PREFIX) -} + const nodeIndex = new Map(graphData.nodes.map((node, index) => [node.id, index])) + const outgoing: Array> = Array.from({ length: graphData.nodes.length }, () => []) -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 }) + 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 }) }) - return { - nodes: [...nodesById.values()], - links: [...linksByKey.values()], - } + 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)) + return new Set(nodes.filter(node => !isExpanderNode(node) && !isClusterNode(node)).map(node => node.id)) } function drawRoundedRect( @@ -399,40 +504,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(() => { @@ -534,6 +652,9 @@ 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 [lastExpandedNodeIds, setLastExpandedNodeIds] = useState>(() => new Set()) // eslint-disable-next-line @typescript-eslint/no-explicit-any const graphRef = useRef(null) @@ -619,8 +740,10 @@ function App() { if (query) { invoke('execute_query', { id: selectedId, query }) .then(data => { + console.info('Graph cluster debug:', data.clusterDebug) setGraphData(data) setLastExpandedNodeIds(new Set()) + setExpandedClusterId(null) setLoading(false) setTimeout(() => { if (graphRef.current) { @@ -635,8 +758,10 @@ function App() { } else { invoke('get_graph', { id: selectedId }) .then(data => { + console.info('Graph cluster debug:', data.clusterDebug) setGraphData(data) setLastExpandedNodeIds(new Set()) + setExpandedClusterId(null) setLoading(false) setTimeout(() => { if (graphRef.current) { @@ -697,15 +822,14 @@ 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) }) .catch(err => { @@ -715,20 +839,50 @@ 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 expandedCluster = useMemo(() => ( + expandedClusterId === null + ? null + : selectedCluster?.clusters.find(cluster => cluster.clusterId === expandedClusterId) || null + ), [expandedClusterId, selectedCluster]) + const visibleGraphData = useMemo(() => ( + clusterCollapsed && selectedCluster + ? collapseGraphByClusterLevel(normalizedGraphData, selectedCluster, expandedClusterId) + : normalizedGraphData + ), [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 */ + 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]) @@ -736,21 +890,22 @@ 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((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) => { @@ -763,13 +918,25 @@ 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) => { + const clusterNode = parseClusterNodeId(nodeId) + if (clusterNode) { + setSelectedClusterLevel(clusterNode.level) + setExpandedClusterId(clusterNode.clusterId) + setClusterCollapsed(true) + return + } + handleNodeClick(nodeId) + }, [handleNodeClick]) + // 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) { @@ -848,12 +1015,44 @@ function App() {
- {loading ? 'Loading...' : `${graphData.nodes.length} nodes, ${graphData.links.length} edges`} + {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` + : `${graphData.nodes.length} nodes, ${graphData.links.length} edges`} {error && {error}}
+ {clusterLevels.length > 0 && ( +
+ + +
+ )}