diff --git a/modules/CommunityClusters/LICENSE b/modules/CommunityClusters/LICENSE new file mode 100644 index 000000000..cfa8438bc --- /dev/null +++ b/modules/CommunityClusters/LICENSE @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2026 Johan Myrberger + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/modules/CommunityClusters/MANUAL.md b/modules/CommunityClusters/MANUAL.md new file mode 100644 index 000000000..1d58163fe --- /dev/null +++ b/modules/CommunityClusters/MANUAL.md @@ -0,0 +1,240 @@ +# Community Clusters — User Manual + +Community Clusters is a layout for Gephi. It arranges a network so that each community forms its own +visible cluster, with the clusters themselves placed apart from each other. Inside a cluster, nodes +are arranged by their ties, so well-connected nodes sit together and weakly connected ones drift to the edge. + +The layout is inspired by the Clusters view of SoFab (see [Credits](#7-credits)), and is built for Gephi 0.11.3. + +--- + +## 1. Quick start + +**Using communities you already have** + +1. Open your graph in Gephi. +2. Run **Statistics > Modularity** (Run). This creates the node column `modularity_class`. +3. Open **Window > Layout** and choose **Community Clusters** in the dropdown. +4. Press **Run**. The layout stops by itself when it has settled. + +**Letting the plugin find the communities** + +1. Open your graph. +2. In the layout settings, clear the **Cluster column** field (or enter a name that does not exist). +3. Press **Run**. The plugin detects communities with the Leiden algorithm and, by default, saves + them to a node column called `community_cluster` so you can colour nodes by it + (Appearance > Nodes > Partition > `community_cluster`). + +--- + +## 2. How it works + +1. **Find the communities.** Each node gets a cluster, either from the column you name or from Leiden. +2. **Give each cluster a home.** One circle is drawn per cluster, with an area that grows with the number of + nodes in it, and the circles are packed together without overlapping, largest first. + The centre of each circle is that cluster's home position. +3. **Simulate.** Nodes start close to their cluster's home and a force simulation runs: + - **Ties** pull connected nodes towards their resting length. Ties inside a cluster act at full + strength; ties between clusters are weakened (see *Inter-cluster tie factor*), so they do not + drag clusters into each other. + - **Repulsion** pushes nearby nodes apart. It only acts within a fixed distance of 140 units + (multiplied by *Scale*), so distant clusters do not push each other around. + - **Collision** stops nodes from overlapping. + - **Cluster pull** draws every node towards its cluster's home. +4. **Cool down.** The forces fade out over time. When they are negligible the layout stops itself. + +The layout works on the visible graph, so active filters are respected. Nodes you have fixed +(locked) in Gephi do not move, but other nodes still react to them. + +--- + +## 3. Settings + +The settings are grouped in three categories in the layout panel. Defaults are shown in brackets. + +### 3.1 Clusters + +#### Cluster column (default: `modularity_class`) + +The node column that says which community each node belongs to. Any column works, whether numbers or +text: nodes with the same value form one cluster. + +- If the column exists, it is used as it is. No community detection is run. +- If the field is empty, or names a column that does not exist, Leiden community detection runs instead. + A name that was typed but not found also writes a warning to the Gephi log, so typos are easy to spot. +- The name is matched against the column id and the column title, ignoring upper and lower case. +- Nodes with an empty value in the column are grouped together as one cluster. + +**Tip:** `modularity_class` is the column created by Gephi's own Modularity statistic, so the default +uses its result whenever you have run it. If you have run Modularity but want the plugin's Leiden +instead, change this field to something that does not exist, for example `none`. + +#### Resolution (default: 1.0, minimum 0.01) + +Only used when Leiden runs. Controls how fine-grained the detected communities are. + +| Value | Effect | +|---|---| +| Below 1 | Fewer, larger communities | +| 1 | Standard modularity | +| Above 1 | More, smaller communities | + +If the clusters are too coarse and merge groups you see as distinct, raise it (try 1.5 or 2). If you +get many small fragments, lower it (try 0.5). + +#### Save clusters to column (default: on) + +Only used when Leiden runs. When on, the detected community of each node is written to an integer node column +named `community_cluster`. It is overwritten each time Leiden runs. You can then: + +- colour nodes by it (Appearance > Nodes > Partition), +- use it in the Data Laboratory or in filters, +- type `community_cluster` in **Cluster column** to reuse exactly this result later without + running detection again. This is also a way to freeze a result so it does not change. + +Turn it off if you do not want an extra column in your data. + +#### Cluster pull (default: 0.14, minimum 0) + +How strongly each node is drawn towards its cluster's home position. This is the main control for how +separated the clusters look. + +| Value | Effect | +|---|---| +| 0 | No home position; ties and repulsion alone decide, and clusters may blend | +| 0.05 – 0.1 | Loose clusters, ties between clusters still have visible influence | +| 0.14 | Default; clear clusters | +| 0.3 – 0.6 | Tight, round, well separated blobs | + +Very high values can squeeze nodes into dense balls where collisions have to push them apart. + +#### Inter-cluster tie factor (default: 0.03, minimum 0) + +How strong ties between clusters are compared to ties inside a cluster. At 0.03 a tie between two +clusters pulls only 3 % as hard as a tie within one. + +- **Lower (towards 0):** clusters ignore each other's ties, so the layout shows clusters as separate + islands. +- **Higher (towards 1):** between-cluster ties draw related clusters closer together, so the + overall structure of the network is easier to read, at the price of less clean separation. + At 1 the cluster ties are treated like all others. + +### 3.2 Forces + +#### Repulsion (default: 40, minimum 0) + +How hard nodes push each other apart. Higher values spread nodes out within their clusters and give +larger clusters; lower values pack them densely. Repulsion only acts within 140 units (times *Scale*). + +#### Tie length (default: 24, minimum 0) + +The resting length of a tie, added to the sizes of the two nodes it connects. Longer ties give a more +open layout. It works together with *Repulsion*: if you raise one, you often want to raise the other. + +#### Tie strength (default: 1, minimum 0.01) + +Multiplies how hard ties pull their ends together. Internally each tie's strength is divided by the +smaller of its two nodes' number of ties, so hubs are not pulled apart, and capped at 1. The result: +increasing this setting mostly affects nodes with few ties, and above a certain point it has no +additional effect. Lower values loosen the layout, with *Repulsion* dominating. + +#### Node spacing (default: 2, minimum 0) + +Extra room kept around each node by the collision force, in addition to the node's own size. +Raise it to keep node labels or outlines from touching; set it to 0 to let nodes touch. + +#### Scale (default: 1, minimum 0.01) + +Multiplies all distances in the layout together (tie length, spacing, repulsion range, size of the +cluster circles and the 140-unit repulsion range), and keeps forces in proportion. The plugin treats +Gephi's node *size* as the node radius. + +- If your nodes are large (for example sizes 50 to 200), nodes will overlap and clusters will be + crowded. Raise *Scale* until the layout looks right. +- If your nodes are very small, lower it. + +### 3.3 Run + +#### Settling speed (default: 0.0228, range 0.0005 – 0.5) + +How quickly the simulation cools down. It decides how long the layout runs before it stops by itself. + +| Value | Approx. number of steps | +|---|---| +| 0.1 | 65 | +| 0.0228 | 300 | +| 0.01 | 690 | +| 0.005 | 1,380 | + +Lower values take longer but usually give a more even, better untangled result. Higher values are +quick for a first look at a large graph. + +#### Random seed (default: 42) + +Seeds the random starting positions and the Leiden algorithm. With the same graph, same settings and +same seed you get the same result every time. Change it to get a different arrangement, which is +useful if you want to see whether a feature of the layout is stable. + +#### Reset positions (default: on) + +- **On:** every run starts from fresh positions around each cluster's home. +- **Off:** the run starts from the current positions of the nodes. Use it to continue a layout that + has already been run, or to refine it with different settings without starting over. If all nodes + are at the same position, leave it on. + +Fixed nodes always keep their current position. + +--- + +## 4. Recommended workflows + +**Presenting known communities.** Run Modularity (or use your own column), choose Community Clusters, +leave the defaults. Raise *Cluster pull* to 0.3 for cleaner separation. + +**Exploring how clusters relate.** Raise *Inter-cluster tie factor* to 0.2 – 0.5, lower *Cluster pull* to +0.08. Clusters with many ties between them will move closer. + +**Tuning the number of communities.** Clear *Cluster column*, run, and adjust *Resolution* until the +number of clusters suits your question. When satisfied, type `community_cluster` into *Cluster column* +to lock that partition. + +**Polishing.** After a first run, lower *Settling speed* to 0.008 and run again with *Reset positions* +off. Then run Gephi's **Noverlap** or **Label Adjust** if you need to clean up labels. + +--- + +## 5. Notes and limits + +- **Isolated nodes** (nodes without ties) cannot be assigned to a community by Leiden, so each one becomes + its own one-node cluster and gets its own place in the packing. A graph with many isolated nodes will + therefore show many tiny circles. Remove them with a filter (Degree Range) before running if this is not wanted. +- **Directed graphs** are treated as undirected. Parallel edges and edges in both directions are added + together, and edge weights are used. Self-loops and edges with zero or negative weight are ignored. +- **Leiden quality** is measured with modularity. Like all modularity methods it can miss very small + communities in very large graphs. Raise *Resolution* if that matters. +- **Speed.** Leiden takes a few seconds for 50,000 nodes and 250,000 edges. The layout itself takes + tens of milliseconds per step for tens of thousands of nodes. +- **Gephi's own Modularity column** (`modularity_class`) is produced by a different implementation (Louvain) + than the Leiden used here, so the two can give somewhat different communities for the same graph. + +--- + +## 6. Troubleshooting + +| Problem | What to try | +|---|---| +| The layout is not in the dropdown | Open a project first; check the plugin is listed in the root `pom.xml` and the build succeeded | +| Everything ends up as one cluster | The cluster column has the same value everywhere. Choose another column, or clear the field to use Leiden | +| Far too many clusters | Lower *Resolution*; check for isolated nodes (see above) | +| Clusters overlap each other | Raise *Cluster pull*; lower *Inter-cluster tie factor*; if nodes are large, raise *Scale* | +| Nodes overlap inside clusters | Raise *Node spacing* or *Scale*; raise *Repulsion* | +| Clusters look like perfect circles with no structure | Lower *Cluster pull* and raise *Tie strength* so ties shape the inside | +| The layout never seems to stop | Lower values of *Settling speed* run longer; raise it to finish sooner | +| The layout stops immediately | The graph is empty, or the previous run is finished: press Run again | +| A warning about a column in the Gephi log | The name in *Cluster column* was not found, so Leiden was used | + +--- + +## 7. Credits + +The layout is inspired by Fierăscu, S. I. (2026). *SoFab: Network analysis for research and practice* (Version 0.9.0) [Computer software]. diff --git a/modules/CommunityClusters/README.md b/modules/CommunityClusters/README.md new file mode 100644 index 000000000..301625fde --- /dev/null +++ b/modules/CommunityClusters/README.md @@ -0,0 +1,47 @@ +# Community Clusters + +Gephi layout plugin that gathers each community of a network into its own cluster. +See `MANUAL.md` for usage and a description of every setting. + +## Build and run + +```bat +cd /d C:\gephidev\gephi-plugins +mvn clean package +mvn org.gephi:gephi-maven-plugin:run +``` + +The module must be listed in the root `pom.xml`: + +```xml +modules/CommunityClusters +``` + +## Source files + +| File | Role | +|---|---| +| `CommunityClusters.java` | The Gephi layout: properties, reading the graph, writing positions | +| `CommunityClustersBuilder.java` | Registers the layout with Gephi | +| `ClusterForceSimulation.java` | The force simulation (pure Java) | +| `CirclePacker.java` | Packs one circle per cluster to give each a home position | +| `Leiden.java` | Community detection (pure Java) | +| `Bundle.properties` | All user-visible text (setting names, descriptions, layout name) | +| `src/test/java/...` | JUnit tests for Leiden, the circle packer and the force simulation | + + +## Tests + +`mvn clean package` runs the unit tests automatically. To run only this module's tests: + +```bat +mvn -pl modules/CommunityClusters -am test +``` + +## License + +Apache License 2.0, see `LICENSE`. + +## Credits + +The layout is inspired by Fierăscu, S. I. (2026). *SoFab: Network analysis for research and practice* (Version 0.9.0) [Computer software]. diff --git a/modules/CommunityClusters/pom.xml b/modules/CommunityClusters/pom.xml new file mode 100644 index 000000000..7036f5cf8 --- /dev/null +++ b/modules/CommunityClusters/pom.xml @@ -0,0 +1,72 @@ + + + 4.0.0 + + gephi-plugin-parent + org.gephi + 0.11.4 + + + myrberger + community-clusters + 0.1.0 + nbm + + Community Clusters + + + + org.gephi + layout-api + + + org.gephi + graph-api + + + org.netbeans.api + org-openide-util-lookup + + + org.netbeans.api + org-openide-util + + + org.netbeans.api + org-netbeans-modules-nbjunit + test + + + + + + + org.apache.netbeans.utilities + nbm-maven-plugin + + Apache 2.0 + LICENSE + Johan Myrberger + johan@thekillerattitude.com + + https://github.com/johanm/gephi-plugins.git + + + + + + + + + + + + oss-sonatype + oss-sonatype + https://oss.sonatype.org/content/repositories/snapshots/ + + true + + + + diff --git a/modules/CommunityClusters/src/main/java/myrberger/communityclusters/CirclePacker.java b/modules/CommunityClusters/src/main/java/myrberger/communityclusters/CirclePacker.java new file mode 100644 index 000000000..676b85685 --- /dev/null +++ b/modules/CommunityClusters/src/main/java/myrberger/communityclusters/CirclePacker.java @@ -0,0 +1,154 @@ +package myrberger.communityclusters; + +/** + * Port of d3-hierarchy's packSiblings (front-chain circle packing). + * Pure Java, no Gephi dependency. + */ +final class CirclePacker { + + private CirclePacker() { + } + + private static final class Node { + final int idx; + Node next, previous; + + Node(int idx) { + this.idx = idx; + } + } + + /** + * Packs circles of the given radii. Returns {x[], y[]}, centred (by bounding box) on the origin. + * Radii must be positive. + */ + static double[][] pack(double[] r) { + final int n = r.length; + final double[] x = new double[n]; + final double[] y = new double[n]; + if (n == 0) { + return new double[][]{x, y}; + } + if (n == 1) { + return new double[][]{x, y}; + } + + // first two circles + x[0] = -r[1]; + y[0] = 0; + x[1] = r[0]; + y[1] = 0; + if (n > 2) { + place(x, y, r, 1, 0, 2); + + Node a = new Node(0), b = new Node(1), c = new Node(2); + a.next = c.previous = b; + b.next = a.previous = c; + c.next = b.previous = a; + + pack: + for (int i = 3; i < n; ++i) { + place(x, y, r, a.idx, b.idx, i); + c = new Node(i); + + Node j = b.next, k = a.previous; + double sj = r[b.idx], sk = r[a.idx]; + do { + if (sj <= sk) { + if (intersects(x, y, r, j.idx, c.idx)) { + b = j; + a.next = b; + b.previous = a; + --i; + continue pack; + } + sj += r[j.idx]; + j = j.next; + } else { + if (intersects(x, y, r, k.idx, c.idx)) { + a = k; + a.next = b; + b.previous = a; + --i; + continue pack; + } + sk += r[k.idx]; + k = k.previous; + } + } while (j != k.next); + + // insert c between a and b + c.previous = a; + c.next = b; + a.next = c; + b.previous = c; + b = c; + + // closest pair to the centroid + double aa = score(x, y, r, a); + Node cc = c; + while ((cc = cc.next) != b) { + double ca = score(x, y, r, cc); + if (ca < aa) { + a = cc; + aa = ca; + } + } + b = a.next; + } + } + + // centre the bounding box on the origin + double minX = Double.POSITIVE_INFINITY, maxX = Double.NEGATIVE_INFINITY; + double minY = Double.POSITIVE_INFINITY, maxY = Double.NEGATIVE_INFINITY; + for (int i = 0; i < n; i++) { + minX = Math.min(minX, x[i] - r[i]); + maxX = Math.max(maxX, x[i] + r[i]); + minY = Math.min(minY, y[i] - r[i]); + maxY = Math.max(maxY, y[i] + r[i]); + } + double cx = (minX + maxX) / 2, cy = (minY + maxY) / 2; + for (int i = 0; i < n; i++) { + x[i] -= cx; + y[i] -= cy; + } + return new double[][]{x, y}; + } + + private static void place(double[] x, double[] y, double[] r, int b, int a, int c) { + double dx = x[b] - x[a], dy = y[b] - y[a], d2 = dx * dx + dy * dy; + if (d2 > 0) { + double a2 = r[a] + r[c]; + a2 *= a2; + double b2 = r[b] + r[c]; + b2 *= b2; + if (a2 > b2) { + double px = (d2 + b2 - a2) / (2 * d2); + double py = Math.sqrt(Math.max(0, b2 / d2 - px * px)); + x[c] = x[b] - px * dx - py * dy; + y[c] = y[b] - px * dy + py * dx; + } else { + double px = (d2 + a2 - b2) / (2 * d2); + double py = Math.sqrt(Math.max(0, a2 / d2 - px * px)); + x[c] = x[a] + px * dx - py * dy; + y[c] = y[a] + px * dy + py * dx; + } + } else { + x[c] = x[a] + r[c]; + y[c] = y[a]; + } + } + + private static boolean intersects(double[] x, double[] y, double[] r, int a, int b) { + double dr = r[a] + r[b] - 1e-6, dx = x[b] - x[a], dy = y[b] - y[a]; + return dr > 0 && dr * dr > dx * dx + dy * dy; + } + + private static double score(double[] x, double[] y, double[] r, Node node) { + int a = node.idx, b = node.next.idx; + double ab = r[a] + r[b]; + double dx = (x[a] * r[b] + x[b] * r[a]) / ab; + double dy = (y[a] * r[b] + y[b] * r[a]) / ab; + return dx * dx + dy * dy; + } +} diff --git a/modules/CommunityClusters/src/main/java/myrberger/communityclusters/ClusterForceSimulation.java b/modules/CommunityClusters/src/main/java/myrberger/communityclusters/ClusterForceSimulation.java new file mode 100644 index 000000000..e829e0a75 --- /dev/null +++ b/modules/CommunityClusters/src/main/java/myrberger/communityclusters/ClusterForceSimulation.java @@ -0,0 +1,370 @@ +package myrberger.communityclusters; + +import java.util.Arrays; +import java.util.Random; + +/** + * Port of the "Clusters" view of SoFab: a d3-force style simulation in which every node is pulled + * towards a home position for its cluster, ties inside a cluster keep full strength and ties between + * clusters are weakened. Pure Java, no Gephi dependency, so it can be unit tested on its own. + */ +final class ClusterForceSimulation { + + /** Tunable dials; defaults are SoFab's. */ + static final class Params { + double repel = 40; // many-body strength + double tieLen = 24; // link distance on top of the two node radii + double tieStr = 1; // multiplies the per-tie strength + double spacing = 2; // extra room kept by the collision force + double clusterPull = 0.14; // pull towards the cluster's home + double cooling = 0.0228; // alphaDecay + double interTieFactor = 0.03;// strength multiplier for ties between clusters + double scale = 1; // multiplies all distances + double repelDistanceMax = 140; + long seed = 42; + boolean resetPositions = true; + } + + private static final double ALPHA_MIN = 0.001; + private static final double VELOCITY_DECAY = 0.6; // d3: 1 - 0.4 + + final int n; + final double[] x, y, vx, vy; + private final double[] radius; + private final int[] cluster; + private final boolean[] fixed; + private final double[] homeX, homeY; // per node + private final int[] src, dst; + private final double[] linkDist, linkStr, linkBias; + private final Params p; + private final Random rng; + private double alpha = 1; + private int[] cellStart = new int[0]; + private int[] cellItems = new int[0]; + + /** + * @param radius node radius (graph units) + * @param cluster cluster index per node, 0..k-1 + * @param fixed nodes that must not move + * @param startX current positions, used only when {@code p.resetPositions} is false + */ + ClusterForceSimulation(double[] radius, int[] cluster, boolean[] fixed, double[] startX, double[] startY, + int[] edgeSrc, int[] edgeDst, Params params) { + this.n = radius.length; + this.radius = radius; + this.cluster = cluster; + this.fixed = fixed; + this.p = params; + this.rng = new Random(params.seed); + x = new double[n]; + y = new double[n]; + vx = new double[n]; + vy = new double[n]; + homeX = new double[n]; + homeY = new double[n]; + + // drop self loops + int m = 0; + for (int i = 0; i < edgeSrc.length; i++) { + if (edgeSrc[i] != edgeDst[i]) { + m++; + } + } + src = new int[m]; + dst = new int[m]; + m = 0; + for (int i = 0; i < edgeSrc.length; i++) { + if (edgeSrc[i] != edgeDst[i]) { + src[m] = edgeSrc[i]; + dst[m] = edgeDst[i]; + m++; + } + } + + computeHomes(); + initPositions(startX, startY); + + // link force setup (d3: strength = 1/min(degree) scaled by the dial, held at 1) + double[] cnt = new double[n]; + for (int i = 0; i < m; i++) { + cnt[src[i]]++; + cnt[dst[i]]++; + } + linkDist = new double[m]; + linkStr = new double[m]; + linkBias = new double[m]; + for (int i = 0; i < m; i++) { + int s = src[i], t = dst[i]; + linkDist[i] = p.tieLen * p.scale + radius[s] + radius[t]; + double base = Math.min(1, p.tieStr / Math.min(cnt[s], cnt[t])); + linkStr[i] = cluster[s] == cluster[t] ? base : base * p.interTieFactor; + linkBias[i] = cnt[s] / (cnt[s] + cnt[t]); + } + } + + private void computeHomes() { + int k = 0; + for (int c : cluster) { + k = Math.max(k, c + 1); + } + if (k == 0) { + return; + } + int[] counts = new int[k]; + double maxR = 0; + for (int i = 0; i < n; i++) { + counts[cluster[i]]++; + maxR = Math.max(maxR, radius[i]); + } + // largest cluster first; stable for ties + Integer[] order = new Integer[k]; + for (int i = 0; i < k; i++) { + order[i] = i; + } + Arrays.sort(order, (a, b) -> Integer.compare(counts[b], counts[a])); + + double unit = (9 + maxR * 0.9) * p.scale; + double[] r = new double[k]; + for (int i = 0; i < k; i++) { + r[i] = Math.sqrt(counts[order[i]]) * unit + 12 * p.scale; + } + double[][] xy = CirclePacker.pack(r); + double[] cx = new double[k], cy = new double[k]; + for (int i = 0; i < k; i++) { + cx[order[i]] = xy[0][i]; + cy[order[i]] = xy[1][i]; + } + for (int i = 0; i < n; i++) { + homeX[i] = cx[cluster[i]]; + homeY[i] = cy[cluster[i]]; + } + } + + private void initPositions(double[] startX, double[] startY) { + for (int i = 0; i < n; i++) { + if (!p.resetPositions || fixed[i]) { + x[i] = startX[i]; + y[i] = startY[i]; + } else { + x[i] = homeX[i] + (rng.nextDouble() - 0.5) * 30 * p.scale; + y[i] = homeY[i] + (rng.nextDouble() - 0.5) * 30 * p.scale; + } + } + } + + boolean isConverged() { + return alpha < ALPHA_MIN; + } + + double alpha() { + return alpha; + } + + /** One simulation step, in the same order d3 applies its forces. */ + void tick() { + alpha += (0 - alpha) * p.cooling; + applyLinks(); + applyCharge(); + applyCollide(); + applyHome(); + for (int i = 0; i < n; i++) { + if (fixed[i]) { + vx[i] = 0; + vy[i] = 0; + } else { + vx[i] *= VELOCITY_DECAY; + vy[i] *= VELOCITY_DECAY; + x[i] += vx[i]; + y[i] += vy[i]; + } + } + } + + private double jiggle() { + return (rng.nextDouble() - 0.5) * 1e-6; + } + + private void applyLinks() { + for (int i = 0; i < src.length; i++) { + int s = src[i], t = dst[i]; + double dx = x[t] + vx[t] - x[s] - vx[s]; + double dy = y[t] + vy[t] - y[s] - vy[s]; + if (dx == 0) { + dx = jiggle(); + } + if (dy == 0) { + dy = jiggle(); + } + double l = Math.sqrt(dx * dx + dy * dy); + l = (l - linkDist[i]) / l * alpha * linkStr[i]; + dx *= l; + dy *= l; + double b = linkBias[i]; + vx[t] -= dx * b; + vy[t] -= dy * b; + b = 1 - b; + vx[s] += dx * b; + vy[s] += dy * b; + } + } + + // ---- uniform grid for the two short-range forces ----------------------------------------------- + + private int gw, gh; + private double gx0, gy0, gcell; + + private void buildGrid(double[] px, double[] py, double minCell) { + double minX = Double.POSITIVE_INFINITY, minY = Double.POSITIVE_INFINITY; + double maxX = Double.NEGATIVE_INFINITY, maxY = Double.NEGATIVE_INFINITY; + for (int i = 0; i < n; i++) { + minX = Math.min(minX, px[i]); + maxX = Math.max(maxX, px[i]); + minY = Math.min(minY, py[i]); + maxY = Math.max(maxY, py[i]); + } + // keep the number of cells proportional to n; bigger cells stay correct, they just test more pairs + double extent = Math.max(maxX - minX, maxY - minY); + gcell = Math.max(minCell, extent / Math.max(1, Math.sqrt(4.0 * n))); + if (!(gcell > 0)) { + gcell = 1; + } + gx0 = minX; + gy0 = minY; + gw = (int) ((maxX - minX) / gcell) + 1; + gh = (int) ((maxY - minY) / gcell) + 1; + int cells = gw * gh; + if (cellStart.length < cells + 1) { + cellStart = new int[cells + 1]; + } else { + Arrays.fill(cellStart, 0, cells + 1, 0); + } + if (cellItems.length < n) { + cellItems = new int[n]; + } + int[] cellOf = new int[n]; + for (int i = 0; i < n; i++) { + int cx = (int) ((px[i] - gx0) / gcell), cy = (int) ((py[i] - gy0) / gcell); + cellOf[i] = cy * gw + cx; + cellStart[cellOf[i] + 1]++; + } + for (int c = 0; c < cells; c++) { + cellStart[c + 1] += cellStart[c]; + } + int[] fill = new int[cells]; + for (int i = 0; i < n; i++) { + cellItems[cellStart[cellOf[i]] + fill[cellOf[i]]++] = i; + } + } + + /** Many-body repulsion with a hard distance cut-off (d3's forceManyBody with distanceMax). */ + private void applyCharge() { + if (n < 2 || p.repel == 0) { + return; + } + double dMax = p.repelDistanceMax * p.scale; + double dMax2 = dMax * dMax; + double strength = -p.repel * p.scale * p.scale; + buildGrid(x, y, dMax); + for (int i = 0; i < n; i++) { + if (fixed[i]) { + continue; + } + int cx = (int) ((x[i] - gx0) / gcell), cy = (int) ((y[i] - gy0) / gcell); + for (int gy = Math.max(0, cy - 1); gy <= Math.min(gh - 1, cy + 1); gy++) { + for (int gxx = Math.max(0, cx - 1); gxx <= Math.min(gw - 1, cx + 1); gxx++) { + int c = gy * gw + gxx; + for (int q = cellStart[c]; q < cellStart[c + 1]; q++) { + int j = cellItems[q]; + if (j == i) { + continue; + } + double dx = x[j] - x[i], dy = y[j] - y[i]; + if (dx == 0) { + dx = jiggle(); + } + if (dy == 0) { + dy = jiggle(); + } + double l = dx * dx + dy * dy; + if (l >= dMax2) { + continue; + } + if (l < 1) { + l = Math.sqrt(l); + } + double w = strength * alpha / l; + vx[i] += dx * w; + vy[i] += dy * w; + } + } + } + } + } + + /** d3's forceCollide with strength 1 and one iteration. */ + private void applyCollide() { + if (n < 2) { + return; + } + double sp = p.spacing * p.scale; + double maxR = 0; + for (int i = 0; i < n; i++) { + maxR = Math.max(maxR, radius[i] + sp); + } + double[] px = new double[n], py = new double[n]; + for (int i = 0; i < n; i++) { + px[i] = x[i] + vx[i]; + py[i] = y[i] + vy[i]; + } + buildGrid(px, py, 2 * maxR); + for (int i = 0; i < n; i++) { + double ri = radius[i] + sp, ri2 = ri * ri; + double xi = px[i], yi = py[i]; + int cx = (int) ((xi - gx0) / gcell), cy = (int) ((yi - gy0) / gcell); + for (int gy = Math.max(0, cy - 1); gy <= Math.min(gh - 1, cy + 1); gy++) { + for (int gxx = Math.max(0, cx - 1); gxx <= Math.min(gw - 1, cx + 1); gxx++) { + int c = gy * gw + gxx; + for (int q = cellStart[c]; q < cellStart[c + 1]; q++) { + int j = cellItems[q]; + if (j <= i) { + continue; + } + double rj = radius[j] + sp; + double rr = ri + rj; + double dx = xi - (x[j] + vx[j]), dy = yi - (y[j] + vy[j]); + double l = dx * dx + dy * dy; + if (l < rr * rr) { + if (dx == 0) { + dx = jiggle(); + l += dx * dx; + } + if (dy == 0) { + dy = jiggle(); + l += dy * dy; + } + l = Math.sqrt(l); + l = (rr - l) / l; + dx *= l; + dy *= l; + double share = (rj * rj) / (ri2 + rj * rj); + vx[i] += dx * share; + vy[i] += dy * share; + share = 1 - share; + vx[j] -= dx * share; + vy[j] -= dy * share; + } + } + } + } + } + } + + /** forceX / forceY towards the cluster's home. */ + private void applyHome() { + double k = p.clusterPull * alpha; + for (int i = 0; i < n; i++) { + vx[i] += (homeX[i] - x[i]) * k; + vy[i] += (homeY[i] - y[i]) * k; + } + } +} diff --git a/modules/CommunityClusters/src/main/java/myrberger/communityclusters/CommunityClusters.java b/modules/CommunityClusters/src/main/java/myrberger/communityclusters/CommunityClusters.java new file mode 100644 index 000000000..95c8e17e3 --- /dev/null +++ b/modules/CommunityClusters/src/main/java/myrberger/communityclusters/CommunityClusters.java @@ -0,0 +1,321 @@ +package myrberger.communityclusters; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.logging.Level; +import java.util.logging.Logger; +import org.gephi.graph.api.Column; +import org.gephi.graph.api.Edge; +import org.gephi.graph.api.Graph; +import org.gephi.graph.api.GraphModel; +import org.gephi.graph.api.Node; +import org.gephi.layout.spi.Layout; +import org.gephi.layout.spi.LayoutBuilder; +import org.gephi.layout.spi.LayoutProperty; +import org.openide.util.NbBundle; + +/** + * Gephi layout that reproduces the "Clusters" view of SoFab. Each community gets a home position + * (circles packed by size); nodes are pulled towards their home while ties and repulsion shape the + * inside of each cluster. + */ +public class CommunityClusters implements Layout { + + private static final Logger LOG = Logger.getLogger(CommunityClusters.class.getName()); + private static final String SAVED_COLUMN = "community_cluster"; + + private final LayoutBuilder builder; + private GraphModel graphModel; + private ClusterForceSimulation sim; + private Node[] nodes; + private int[] lastCluster; + + // dials (defaults are SoFab's) + private String clusterColumn; + private Double repulsion; + private Double tieLength; + private Double tieStrength; + private Double nodeSpacing; + private Double clusterPull; + private Double settlingSpeed; + private Double interClusterTieFactor; + private Double scale; + private Integer randomSeed; + private Double resolution; + private Boolean saveClusters; + private Boolean resetPositions; + + public CommunityClusters(LayoutBuilder builder) { + this.builder = builder; + resetPropertiesValues(); + } + + @Override + public void resetPropertiesValues() { + clusterColumn = "modularity_class"; + repulsion = 40.0; + tieLength = 24.0; + tieStrength = 1.0; + nodeSpacing = 2.0; + clusterPull = 0.14; + settlingSpeed = 0.0228; + interClusterTieFactor = 0.03; + scale = 1.0; + randomSeed = 42; + resolution = 1.0; + saveClusters = true; + resetPositions = true; + } + + @Override + public void setGraphModel(GraphModel graphModel) { + this.graphModel = graphModel; + } + + @Override + public void initAlgo() { + sim = null; + nodes = null; + lastCluster = null; + pendingSave = false; + if (graphModel == null) { + return; + } + Graph graph = graphModel.getGraphVisible(); + graph.readLock(); + try { + nodes = graph.getNodes().toArray(); + int n = nodes.length; + if (n == 0) { + return; + } + Map index = new HashMap<>(n * 2); + double[] radius = new double[n], sx = new double[n], sy = new double[n]; + boolean[] fixed = new boolean[n]; + double maxSize = 0; + for (int i = 0; i < n; i++) { + Node node = nodes[i]; + index.put(node, i); + radius[i] = Math.max(1.0, node.size()); + maxSize = Math.max(maxSize, radius[i]); + sx[i] = node.x(); + sy[i] = node.y(); + fixed[i] = node.isFixed(); + } + + List pairs = new ArrayList<>(); + List weights = new ArrayList<>(); + for (Edge e : graph.getEdges()) { + Integer s = index.get(e.getSource()); + Integer t = index.get(e.getTarget()); + if (s != null && t != null) { + pairs.add(new int[]{s, t}); + weights.add(e.getWeight()); + } + } + int[] es = new int[pairs.size()], ed = new int[pairs.size()]; + for (int i = 0; i < es.length; i++) { + es[i] = pairs.get(i)[0]; + ed[i] = pairs.get(i)[1]; + } + + double[] ew = new double[es.length]; + for (int i = 0; i < ew.length; i++) { + ew[i] = weights.get(i); + } + int[] cluster = readClusters(nodes, es, ed, ew); + + ClusterForceSimulation.Params p = new ClusterForceSimulation.Params(); + p.repel = repulsion; + p.tieLen = tieLength; + p.tieStr = tieStrength; + p.spacing = nodeSpacing; + p.clusterPull = clusterPull; + p.cooling = settlingSpeed; + p.interTieFactor = interClusterTieFactor; + p.scale = scale; + p.seed = randomSeed; + p.resetPositions = resetPositions; + lastCluster = cluster; + sim = new ClusterForceSimulation(radius, cluster, fixed, sx, sy, es, ed, p); + } finally { + graph.readUnlock(); + } + if (pendingSave && nodes != null && lastCluster != null) { + saveClusterColumn(nodes, lastCluster); + } + pendingSave = false; + } + + /** + * Cluster index per node: read from the chosen node column, or, when no column is given or it does not + * exist, detected with Leiden (modularity, resolution and seed from the properties). + */ + private int[] readClusters(Node[] nodes, int[] es, int[] ed, double[] ew) { + int n = nodes.length; + Column col = findColumn(clusterColumn); + if (col != null) { + int[] out = new int[n]; + Map ids = new HashMap<>(); + for (int i = 0; i < n; i++) { + Object v = nodes[i].getAttribute(col); + String key = v == null ? "" : String.valueOf(v); + Integer id = ids.get(key); + if (id == null) { + id = ids.size(); + ids.put(key, id); + } + out[i] = id; + } + return out; + } + if (clusterColumn != null && !clusterColumn.trim().isEmpty()) { + LOG.log(Level.WARNING, "Node column \"{0}\" not found; detecting clusters with Leiden instead.", + clusterColumn); + } + int[] out = Leiden.run(n, es, ed, ew, resolution, randomSeed); + pendingSave = saveClusters; + return out; + } + + private boolean pendingSave; + + /** Stores Leiden's result in a node column so it can be used for colouring and partitioning. */ + private void saveClusterColumn(Node[] nodes, int[] cluster) { + try { + Column col = graphModel.getNodeTable().getColumn(SAVED_COLUMN); + if (col == null) { + col = graphModel.getNodeTable().addColumn(SAVED_COLUMN, Integer.class); + } + if (col.getTypeClass() != Integer.class) { + return; + } + for (int i = 0; i < nodes.length; i++) { + nodes[i].setAttribute(col, cluster[i]); + } + } catch (Exception e) { + LOG.log(Level.WARNING, "Could not save clusters to column " + SAVED_COLUMN, e); + } + } + + private Column findColumn(String name) { + if (name == null || name.trim().isEmpty()) { + return null; + } + Column c = graphModel.getNodeTable().getColumn(name.trim()); + if (c != null) { + return c; + } + for (Column other : graphModel.getNodeTable()) { + if (name.trim().equalsIgnoreCase(other.getId()) || name.trim().equalsIgnoreCase(other.getTitle())) { + return other; + } + } + return null; + } + + @Override + public void goAlgo() { + if (sim == null || nodes == null || graphModel == null) { + return; + } + Graph graph = graphModel.getGraphVisible(); + graph.readLock(); + try { + sim.tick(); + for (int i = 0; i < nodes.length; i++) { + if (!nodes[i].isFixed()) { + nodes[i].setX((float) sim.x[i]); + nodes[i].setY((float) sim.y[i]); + } + } + } finally { + graph.readUnlock(); + } + } + + @Override + public boolean canAlgo() { + return sim != null && !sim.isConverged(); + } + + @Override + public void endAlgo() { + sim = null; + nodes = null; + } + + @Override + public LayoutProperty[] getProperties() { + List props = new ArrayList<>(); + try { + String clusters = msg("CommunityClusters.category.clusters"); + String forces = msg("CommunityClusters.category.forces"); + String run = msg("CommunityClusters.category.run"); + props.add(property(String.class, clusters, "clusterColumn", "getClusterColumn", "setClusterColumn")); + props.add(property(Double.class, clusters, "resolution", "getResolution", "setResolution")); + props.add(property(Boolean.class, clusters, "saveClusters", "getSaveClusters", "setSaveClusters")); + props.add(property(Double.class, clusters, "clusterPull", "getClusterPull", "setClusterPull")); + props.add(property(Double.class, clusters, "interClusterTieFactor", + "getInterClusterTieFactor", "setInterClusterTieFactor")); + props.add(property(Double.class, forces, "repulsion", "getRepulsion", "setRepulsion")); + props.add(property(Double.class, forces, "tieLength", "getTieLength", "setTieLength")); + props.add(property(Double.class, forces, "tieStrength", "getTieStrength", "setTieStrength")); + props.add(property(Double.class, forces, "nodeSpacing", "getNodeSpacing", "setNodeSpacing")); + props.add(property(Double.class, forces, "scale", "getScale", "setScale")); + props.add(property(Double.class, run, "settlingSpeed", "getSettlingSpeed", "setSettlingSpeed")); + props.add(property(Integer.class, run, "randomSeed", "getRandomSeed", "setRandomSeed")); + props.add(property(Boolean.class, run, "resetPositions", "getResetPositions", "setResetPositions")); + } catch (Exception e) { + LOG.log(Level.SEVERE, "Could not create layout properties", e); + } + return props.toArray(new LayoutProperty[0]); + } + + /** Builds a property whose display name and description come from Bundle.properties (".name/.desc"). */ + private LayoutProperty property(Class type, String category, String id, String getter, String setter) + throws Exception { + return LayoutProperty.createProperty(this, type, msg("CommunityClusters." + id + ".name"), category, id, + msg("CommunityClusters." + id + ".desc"), getter, setter); + } + + private static String msg(String key) { + return NbBundle.getMessage(CommunityClusters.class, key); + } + + @Override + public LayoutBuilder getBuilder() { + return builder; + } + + // ---- property accessors (looked up by name through reflection) ---------------------------------- + + public String getClusterColumn() { return clusterColumn; } + public void setClusterColumn(String v) { clusterColumn = v; } + public Double getRepulsion() { return repulsion; } + public void setRepulsion(Double v) { repulsion = Math.max(0, v); } + public Double getTieLength() { return tieLength; } + public void setTieLength(Double v) { tieLength = Math.max(0, v); } + public Double getTieStrength() { return tieStrength; } + public void setTieStrength(Double v) { tieStrength = Math.max(0.01, v); } + public Double getNodeSpacing() { return nodeSpacing; } + public void setNodeSpacing(Double v) { nodeSpacing = Math.max(0, v); } + public Double getClusterPull() { return clusterPull; } + public void setClusterPull(Double v) { clusterPull = Math.max(0, v); } + public Double getSettlingSpeed() { return settlingSpeed; } + public void setSettlingSpeed(Double v) { settlingSpeed = Math.min(0.5, Math.max(0.0005, v)); } + public Double getInterClusterTieFactor() { return interClusterTieFactor; } + public void setInterClusterTieFactor(Double v) { interClusterTieFactor = Math.max(0, v); } + public Double getScale() { return scale; } + public void setScale(Double v) { scale = Math.max(0.01, v); } + public Integer getRandomSeed() { return randomSeed; } + public void setRandomSeed(Integer v) { randomSeed = v; } + public Double getResolution() { return resolution; } + public void setResolution(Double v) { resolution = Math.max(0.01, v); } + public Boolean getSaveClusters() { return saveClusters; } + public void setSaveClusters(Boolean v) { saveClusters = v; } + public Boolean getResetPositions() { return resetPositions; } + public void setResetPositions(Boolean v) { resetPositions = v; } +} diff --git a/modules/CommunityClusters/src/main/java/myrberger/communityclusters/CommunityClustersBuilder.java b/modules/CommunityClusters/src/main/java/myrberger/communityclusters/CommunityClustersBuilder.java new file mode 100644 index 000000000..029d762d7 --- /dev/null +++ b/modules/CommunityClusters/src/main/java/myrberger/communityclusters/CommunityClustersBuilder.java @@ -0,0 +1,58 @@ +package myrberger.communityclusters; + +import javax.swing.Icon; +import javax.swing.JPanel; +import org.gephi.layout.spi.Layout; +import org.gephi.layout.spi.LayoutBuilder; +import org.gephi.layout.spi.LayoutUI; +import org.openide.util.NbBundle; +import org.openide.util.lookup.ServiceProvider; + +@ServiceProvider(service = LayoutBuilder.class) +public class CommunityClustersBuilder implements LayoutBuilder { + + private final UI ui = new UI(); + + @Override + public String getName() { + return NbBundle.getMessage(CommunityClustersBuilder.class, "CommunityClustersBuilder.name"); + } + + @Override + public Layout buildLayout() { + return new CommunityClusters(this); + } + + @Override + public LayoutUI getUI() { + return ui; + } + + private static class UI implements LayoutUI { + + @Override + public String getDescription() { + return NbBundle.getMessage(CommunityClustersBuilder.class, "CommunityClustersBuilder.description"); + } + + @Override + public Icon getIcon() { + return null; + } + + @Override + public JPanel getSimplePanel(Layout layout) { + return null; + } + + @Override + public int getQualityRank() { + return -1; + } + + @Override + public int getSpeedRank() { + return -1; + } + } +} diff --git a/modules/CommunityClusters/src/main/java/myrberger/communityclusters/Leiden.java b/modules/CommunityClusters/src/main/java/myrberger/communityclusters/Leiden.java new file mode 100644 index 000000000..1576c6da6 --- /dev/null +++ b/modules/CommunityClusters/src/main/java/myrberger/communityclusters/Leiden.java @@ -0,0 +1,421 @@ +package myrberger.communityclusters; + +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; + +/** + * Leiden community detection (Traag, Waltman & van Eck 2019) optimising modularity with a + * resolution parameter, on an undirected weighted graph. Pure Java, no Gephi dependency. + */ +final class Leiden { + + private static final double THETA = 0.01; // randomness of the refinement step + private static final double EPS = 1e-12; + + private Leiden() { + } + + /** Compact CSR graph; k[v] is the total strength of v in the ORIGINAL graph. */ + private static final class G { + int n; + int[] start; + int[] adj; + double[] wt; + double[] k; + } + + /** + * @return community index (0..c-1) for each of the n nodes. Parallel edges and both directions are summed; + * self loops and non-positive weights are ignored. + */ + static int[] run(int n, int[] es, int[] ed, double[] ew, double resolution, long seed) { + int[] result = new int[n]; + if (n == 0) { + return result; + } + List> maps = new ArrayList<>(n); + for (int i = 0; i < n; i++) { + maps.add(new HashMap<>()); + } + double[] k = new double[n]; + for (int i = 0; i < es.length; i++) { + int a = es[i], b = ed[i]; + double w = ew == null ? 1.0 : ew[i]; + if (a == b || !(w > 0)) { + continue; + } + maps.get(a).merge(b, w, Double::sum); + maps.get(b).merge(a, w, Double::sum); + k[a] += w; + k[b] += w; + } + double twoM = 0; + for (double v : k) { + twoM += v; + } + if (twoM == 0) { + for (int i = 0; i < n; i++) { + result[i] = i; + } + return result; + } + + Random rng = new Random(seed); + G g0 = build(maps, k); + int[] part = new int[n]; + for (int i = 0; i < n; i++) { + part[i] = i; + } + // Leiden is iterated: each pass starts from the previous partition, until nothing changes + for (int round = 0; round < 10; round++) { + int[] next = pass(g0, part, resolution, twoM, rng); + renumber(next, new int[n]); + boolean same = Arrays.equals(part, next); + part = next; + if (same) { + break; + } + } + return part; + } + + /** One multilevel pass (move, refine, aggregate) starting from the given partition of the original nodes. */ + private static int[] pass(G g0, int[] init, double gamma, double twoM, Random rng) { + int n = g0.n; + G g = g0; + int[] nodeOf = new int[n]; + int[] comm = init.clone(); + for (int i = 0; i < n; i++) { + nodeOf[i] = i; + } + + for (int iter = 0; iter < 100; iter++) { + moveNodes(g, comm, gamma, twoM, rng); + int distinct = countDistinct(comm, g.n); + if (distinct == g.n) { + break; + } + int[] ref = refine(g, comm, gamma, twoM, rng); + int[] map = new int[g.n]; + int m = renumber(ref, map); + boolean byComm = false; + if (m == g.n) { + // refinement merged nothing: aggregate by the communities themselves + byComm = true; + map = new int[g.n]; + m = renumber(comm.clone(), map); + } + int[] newComm = new int[m]; + for (int v = 0; v < g.n; v++) { + newComm[map[v]] = byComm ? map[v] : comm[v]; + } + g = aggregate(g, map, m); + for (int v = 0; v < n; v++) { + nodeOf[v] = map[nodeOf[v]]; + } + comm = compactIds(newComm, m); + } + + int[] out = new int[n]; + for (int v = 0; v < n; v++) { + out[v] = comm[nodeOf[v]]; + } + return out; + } + + // ---- graph construction --------------------------------------------------------------------- + + private static G build(List> maps, double[] k) { + G g = new G(); + g.n = maps.size(); + g.k = k; + g.start = new int[g.n + 1]; + for (int i = 0; i < g.n; i++) { + g.start[i + 1] = g.start[i] + maps.get(i).size(); + } + g.adj = new int[g.start[g.n]]; + g.wt = new double[g.start[g.n]]; + for (int i = 0; i < g.n; i++) { + int p = g.start[i]; + for (Map.Entry e : maps.get(i).entrySet()) { + g.adj[p] = e.getKey(); + g.wt[p] = e.getValue(); + p++; + } + } + return g; + } + + /** Collapses nodes by map[v] -> 0..m-1. Intra-group weight is dropped (it never affects a move). */ + private static G aggregate(G g, int[] map, int m) { + List> maps = new ArrayList<>(m); + for (int i = 0; i < m; i++) { + maps.add(new HashMap<>()); + } + double[] k = new double[m]; + for (int v = 0; v < g.n; v++) { + int a = map[v]; + k[a] += g.k[v]; + for (int p = g.start[v]; p < g.start[v + 1]; p++) { + int b = map[g.adj[p]]; + if (a != b) { + maps.get(a).merge(b, g.wt[p], Double::sum); + } + } + } + return build(maps, k); + } + + // ---- helpers ---------------------------------------------------------------------------------- + + private static int countDistinct(int[] comm, int n) { + boolean[] seen = new boolean[n]; + int c = 0; + for (int i = 0; i < n; i++) { + if (!seen[comm[i]]) { + seen[comm[i]] = true; + c++; + } + } + return c; + } + + /** Renumbers ids in place to 0..m-1 in order of first appearance; map[oldNodeIndex] = new id. */ + private static int renumber(int[] ids, int[] map) { + Map seen = new HashMap<>(); + for (int i = 0; i < ids.length; i++) { + Integer id = seen.get(ids[i]); + if (id == null) { + id = seen.size(); + seen.put(ids[i], id); + } + ids[i] = id; + if (i < map.length) { + map[i] = id; + } + } + return seen.size(); + } + + private static int[] compactIds(int[] ids, int m) { + int[] out = ids.clone(); + renumber(out, new int[0]); + return out; + } + + private static void shuffle(int[] a, Random rng) { + for (int i = a.length - 1; i > 0; i--) { + int j = rng.nextInt(i + 1); + int t = a[i]; + a[i] = a[j]; + a[j] = t; + } + } + + // ---- phase 1: fast local moving ------------------------------------------------------------- + + private static void moveNodes(G g, int[] comm, double gamma, double twoM, Random rng) { + int n = g.n; + double[] K = new double[n]; + int[] cnt = new int[n]; + for (int v = 0; v < n; v++) { + K[comm[v]] += g.k[v]; + cnt[comm[v]]++; + } + int[] free = new int[n]; + int nFree = 0; + for (int c = 0; c < n; c++) { + if (cnt[c] == 0) { + free[nFree++] = c; + } + } + int[] order = new int[n]; + for (int i = 0; i < n; i++) { + order[i] = i; + } + shuffle(order, rng); + ArrayDeque queue = new ArrayDeque<>(n); + boolean[] inQ = new boolean[n]; + for (int v : order) { + queue.add(v); + inQ[v] = true; + } + double[] wTo = new double[n]; + int[] stamp = new int[n]; + int[] touched = new int[n]; + int curStamp = 0; + + while (!queue.isEmpty()) { + int v = queue.poll(); + inQ[v] = false; + int old = comm[v]; + K[old] -= g.k[v]; + cnt[old]--; + + curStamp++; + int nt = 0; + for (int p = g.start[v]; p < g.start[v + 1]; p++) { + int c = comm[g.adj[p]]; + if (stamp[c] != curStamp) { + stamp[c] = curStamp; + wTo[c] = 0; + touched[nt++] = c; + } + wTo[c] += g.wt[p]; + } + double kv = g.k[v]; + double wOld = stamp[old] == curStamp ? wTo[old] : 0; + int best = old; + double bestGain = wOld - gamma * kv * K[old] / twoM; + for (int t = 0; t < nt; t++) { + int c = touched[t]; + if (c == old) { + continue; + } + double gain = wTo[c] - gamma * kv * K[c] / twoM; + if (gain > bestGain + EPS) { + bestGain = gain; + best = c; + } + } + if (bestGain < -EPS) { + // being alone beats every option (gain 0) + if (cnt[old] == 0) { + best = old; + } else { + best = free[--nFree]; + } + } + if (best != old && cnt[old] == 0) { + free[nFree++] = old; + } + comm[v] = best; + K[best] += kv; + cnt[best]++; + if (best != old) { + for (int p = g.start[v]; p < g.start[v + 1]; p++) { + int u = g.adj[p]; + if (comm[u] != best && !inQ[u]) { + inQ[u] = true; + queue.add(u); + } + } + } + } + } + + // ---- phase 2: refinement ---------------------------------------------------------------------- + + private static int[] refine(G g, int[] comm, double gamma, double twoM, Random rng) { + int n = g.n; + int[] ref = new int[n]; + double[] Kref = new double[n]; + int[] size = new int[n]; + double[] ext = new double[n]; + double[] Kc = new double[n]; + for (int v = 0; v < n; v++) { + ref[v] = v; + Kref[v] = g.k[v]; + size[v] = 1; + Kc[comm[v]] += g.k[v]; + for (int p = g.start[v]; p < g.start[v + 1]; p++) { + if (comm[g.adj[p]] == comm[v]) { + ext[v] += g.wt[p]; + } + } + } + int[] order = new int[n]; + for (int i = 0; i < n; i++) { + order[i] = i; + } + shuffle(order, rng); + + double[] wTo = new double[n]; + int[] stamp = new int[n]; + int[] touched = new int[n]; + double[] gains = new double[n + 1]; + int[] cand = new int[n + 1]; + int curStamp = 0; + + for (int v : order) { + if (size[ref[v]] != 1) { + continue; // only singletons may move + } + int c = comm[v]; + double kv = g.k[v]; + // v must be well connected to the rest of its community + if (ext[v] < gamma * kv * (Kc[c] - kv) / twoM - EPS) { + continue; + } + curStamp++; + int nt = 0; + for (int p = g.start[v]; p < g.start[v + 1]; p++) { + int u = g.adj[p]; + if (comm[u] != c) { + continue; + } + int d = ref[u]; + if (stamp[d] != curStamp) { + stamp[d] = curStamp; + wTo[d] = 0; + touched[nt++] = d; + } + wTo[d] += g.wt[p]; + } + int nc = 0; + double maxGain = 0; + cand[nc] = -1; // stay as a singleton + gains[nc++] = 0; + for (int t = 0; t < nt; t++) { + int d = touched[t]; + if (d == ref[v]) { + continue; + } + if (ext[d] < gamma * Kref[d] * (Kc[c] - Kref[d]) / twoM - EPS) { + continue; // D must itself be well connected + } + double gain = wTo[d] - gamma * kv * Kref[d] / twoM; + if (gain >= 0) { + cand[nc] = d; + gains[nc++] = gain; + maxGain = Math.max(maxGain, gain); + } + } + if (nc == 1) { + continue; + } + double sum = 0; + for (int i = 0; i < nc; i++) { + gains[i] = Math.exp((gains[i] - maxGain) / THETA); + sum += gains[i]; + } + double r = rng.nextDouble() * sum; + int pick = nc - 1; + for (int i = 0; i < nc; i++) { + r -= gains[i]; + if (r <= 0) { + pick = i; + break; + } + } + int d = cand[pick]; + if (d < 0) { + continue; + } + int oldRef = ref[v]; + ref[v] = d; + ext[d] += ext[oldRef] - 2 * wTo[d]; + Kref[d] += kv; + size[d]++; + size[oldRef] = 0; + Kref[oldRef] = 0; + ext[oldRef] = 0; + } + return ref; + } +} diff --git a/modules/CommunityClusters/src/main/nbm/manifest.mf b/modules/CommunityClusters/src/main/nbm/manifest.mf new file mode 100644 index 000000000..21a2f3e35 --- /dev/null +++ b/modules/CommunityClusters/src/main/nbm/manifest.mf @@ -0,0 +1,5 @@ +Manifest-Version: 1.0 +OpenIDE-Module-Name: Community Clusters +OpenIDE-Module-Short-Description: Layout that gathers each community of a network into its own cluster. +OpenIDE-Module-Long-Description: A force layout that gives every community a home position and pulls its nodes towards it, while ties and repulsion shape the inside of each cluster. Communities are read from a node column (default modularity_class) or, if no such column exists, detected with the built-in Leiden algorithm. +OpenIDE-Module-Display-Category: Layout diff --git a/modules/CommunityClusters/src/main/resources/myrberger/communityclusters/Bundle.properties b/modules/CommunityClusters/src/main/resources/myrberger/communityclusters/Bundle.properties new file mode 100644 index 000000000..dc3479ae9 --- /dev/null +++ b/modules/CommunityClusters/src/main/resources/myrberger/communityclusters/Bundle.properties @@ -0,0 +1,40 @@ +# Layout and builder +CommunityClustersBuilder.name=Community Clusters +CommunityClustersBuilder.description=Force layout that gathers each community into its own cluster. Uses a community column if you name one, otherwise detects communities with Leiden. + +# Property categories +CommunityClusters.category.clusters=Clusters +CommunityClusters.category.forces=Forces +CommunityClusters.category.run=Run + +# Clusters +CommunityClusters.clusterColumn.name=Cluster column +CommunityClusters.clusterColumn.desc=Node column holding the community of each node (e.g. modularity_class from Statistics > Modularity). Leave empty, or name a column that does not exist, to detect communities with Leiden instead. +CommunityClusters.resolution.name=Resolution +CommunityClusters.resolution.desc=Leiden only: higher gives more and smaller clusters, lower gives fewer and larger ones. +CommunityClusters.saveClusters.name=Save clusters to column +CommunityClusters.saveClusters.desc=Leiden only: write the detected clusters to the node column "community_cluster" so you can colour or partition by it. +CommunityClusters.clusterPull.name=Cluster pull +CommunityClusters.clusterPull.desc=How strongly each node is pulled towards its cluster's place. Higher gives tight, separated blobs; lower lets ties between clusters pull them together. (0 - 0.6) +CommunityClusters.interClusterTieFactor.name=Inter-cluster tie factor +CommunityClusters.interClusterTieFactor.desc=Strength of ties between clusters relative to ties inside a cluster. + +# Forces +CommunityClusters.repulsion.name=Repulsion +CommunityClusters.repulsion.desc=How hard nodes push each other apart (many-body strength). +CommunityClusters.tieLength.name=Tie length +CommunityClusters.tieLength.desc=Resting tie length, added to the two node sizes. +CommunityClusters.tieStrength.name=Tie strength +CommunityClusters.tieStrength.desc=Multiplies how hard ties pull their ends together. +CommunityClusters.nodeSpacing.name=Node spacing +CommunityClusters.nodeSpacing.desc=Extra room kept around each node by the collision force. +CommunityClusters.scale.name=Scale +CommunityClusters.scale.desc=Multiplies all distances. Raise it for graphs with large node sizes. + +# Run +CommunityClusters.settlingSpeed.name=Settling speed +CommunityClusters.settlingSpeed.desc=Cooling rate. Lower searches longer and usually untangles more. (0.005 - 0.1) +CommunityClusters.randomSeed.name=Random seed +CommunityClusters.randomSeed.desc=Seed for the starting positions and for Leiden. +CommunityClusters.resetPositions.name=Reset positions +CommunityClusters.resetPositions.desc=Start from fresh positions around each cluster's place instead of the current ones. diff --git a/modules/CommunityClusters/src/test/java/myrberger/communityclusters/CirclePackerTest.java b/modules/CommunityClusters/src/test/java/myrberger/communityclusters/CirclePackerTest.java new file mode 100644 index 000000000..04479a907 --- /dev/null +++ b/modules/CommunityClusters/src/test/java/myrberger/communityclusters/CirclePackerTest.java @@ -0,0 +1,59 @@ +package myrberger.communityclusters; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public class CirclePackerTest { + + private static void assertNoOverlap(double[] r, double[][] xy) { + for (int i = 0; i < r.length; i++) { + for (int j = i + 1; j < r.length; j++) { + double d = Math.hypot(xy[0][i] - xy[0][j], xy[1][i] - xy[1][j]); + assertTrue("circles " + i + " and " + j + " overlap", d >= r[i] + r[j] - 1e-6); + } + } + } + + @Test + public void emptyAndSingleInput() { + assertEquals("empty", 0, CirclePacker.pack(new double[0])[0].length); + double[][] one = CirclePacker.pack(new double[]{10}); + assertEquals("x", 0.0, one[0][0], 1e-9); + assertEquals("y", 0.0, one[1][0], 1e-9); + } + + @Test + public void twoCirclesTouch() { + double[] r = {10, 5}; + double[][] xy = CirclePacker.pack(r); + double d = Math.hypot(xy[0][0] - xy[0][1], xy[1][0] - xy[1][1]); + assertEquals("distance equals sum of radii", 15.0, d, 1e-9); + } + + @Test + public void manyCirclesDoNotOverlap() { + double[] r = {50, 40, 30, 30, 20, 10, 10, 5, 5, 5, 3, 3, 2, 2, 1}; + assertNoOverlap(r, CirclePacker.pack(r)); + } + + @Test + public void identicalCirclesDoNotOverlap() { + double[] r = new double[40]; + java.util.Arrays.fill(r, 7); + assertNoOverlap(r, CirclePacker.pack(r)); + } + + @Test + public void resultIsCentredOnTheOrigin() { + double[] r = {50, 40, 30, 20, 10}; + double[][] xy = CirclePacker.pack(r); + double minX = Double.MAX_VALUE, maxX = -Double.MAX_VALUE; + for (int i = 0; i < r.length; i++) { + minX = Math.min(minX, xy[0][i] - r[i]); + maxX = Math.max(maxX, xy[0][i] + r[i]); + } + assertEquals("bounding box is symmetric around x=0", 0.0, minX + maxX, 1e-6); + } +} diff --git a/modules/CommunityClusters/src/test/java/myrberger/communityclusters/ClusterForceSimulationTest.java b/modules/CommunityClusters/src/test/java/myrberger/communityclusters/ClusterForceSimulationTest.java new file mode 100644 index 000000000..23f70ad90 --- /dev/null +++ b/modules/CommunityClusters/src/test/java/myrberger/communityclusters/ClusterForceSimulationTest.java @@ -0,0 +1,138 @@ +package myrberger.communityclusters; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import org.junit.Test; + +public class ClusterForceSimulationTest { + + private static final int GROUPS = 5; + private static final int PER_GROUP = 40; + + private static final class Fixture { + final int n = GROUPS * PER_GROUP; + final double[] radius = new double[n]; + final int[] cluster = new int[n]; + final boolean[] fixed = new boolean[n]; + final double[] startX = new double[n]; + final double[] startY = new double[n]; + final int[] src; + final int[] dst; + + Fixture() { + Random r = new Random(1); + List edges = new ArrayList<>(); + for (int i = 0; i < n; i++) { + radius[i] = 5; + cluster[i] = i / PER_GROUP; + } + for (int i = 0; i < n; i++) { + for (int j = i + 1; j < n; j++) { + if (r.nextDouble() < (cluster[i] == cluster[j] ? 0.12 : 0.004)) { + edges.add(new int[]{i, j}); + } + } + } + src = new int[edges.size()]; + dst = new int[edges.size()]; + for (int i = 0; i < src.length; i++) { + src[i] = edges.get(i)[0]; + dst[i] = edges.get(i)[1]; + } + } + + ClusterForceSimulation simulation(ClusterForceSimulation.Params p) { + return new ClusterForceSimulation(radius, cluster, fixed, startX, startY, src, dst, p); + } + } + + private static int runToEnd(ClusterForceSimulation sim) { + int ticks = 0; + while (!sim.isConverged() && ticks < 5000) { + sim.tick(); + ticks++; + } + return ticks; + } + + @Test + public void convergesWithFiniteCoordinates() { + Fixture f = new Fixture(); + ClusterForceSimulation sim = f.simulation(new ClusterForceSimulation.Params()); + int ticks = runToEnd(sim); + assertTrue("converges in a few hundred steps, took " + ticks, ticks > 100 && ticks < 1000); + for (int i = 0; i < f.n; i++) { + assertFalse("x is finite", Double.isNaN(sim.x[i]) || Double.isInfinite(sim.x[i])); + assertFalse("y is finite", Double.isNaN(sim.y[i]) || Double.isInfinite(sim.y[i])); + } + } + + @Test + public void clustersEndUpSeparated() { + Fixture f = new Fixture(); + ClusterForceSimulation sim = f.simulation(new ClusterForceSimulation.Params()); + runToEnd(sim); + double[] cx = new double[GROUPS], cy = new double[GROUPS]; + for (int i = 0; i < f.n; i++) { + cx[f.cluster[i]] += sim.x[i] / PER_GROUP; + cy[f.cluster[i]] += sim.y[i] / PER_GROUP; + } + double spread = 0; + for (int i = 0; i < f.n; i++) { + spread += Math.hypot(sim.x[i] - cx[f.cluster[i]], sim.y[i] - cy[f.cluster[i]]) / f.n; + } + double minGap = Double.MAX_VALUE; + for (int a = 0; a < GROUPS; a++) { + for (int b = a + 1; b < GROUPS; b++) { + minGap = Math.min(minGap, Math.hypot(cx[a] - cx[b], cy[a] - cy[b])); + } + } + assertTrue("centres (" + minGap + " apart) are well separated compared with cluster spread (" + spread + ")", + minGap > 2 * spread); + } + + @Test + public void sameSeedGivesSamePositions() { + Fixture f = new Fixture(); + ClusterForceSimulation a = f.simulation(new ClusterForceSimulation.Params()); + ClusterForceSimulation b = f.simulation(new ClusterForceSimulation.Params()); + for (int t = 0; t < 50; t++) { + a.tick(); + b.tick(); + } + for (int i = 0; i < f.n; i++) { + assertEquals("x of node " + i, a.x[i], b.x[i], 0.0); + assertEquals("y of node " + i, a.y[i], b.y[i], 0.0); + } + } + + @Test + public void fixedNodesDoNotMove() { + Fixture f = new Fixture(); + f.fixed[3] = true; + f.startX[3] = 123; + f.startY[3] = -45; + ClusterForceSimulation sim = f.simulation(new ClusterForceSimulation.Params()); + runToEnd(sim); + assertEquals("x", 123.0, sim.x[3], 0.0); + assertEquals("y", -45.0, sim.y[3], 0.0); + } + + @Test + public void tinyAndDegenerateGraphsAreHandled() { + ClusterForceSimulation.Params p = new ClusterForceSimulation.Params(); + ClusterForceSimulation one = new ClusterForceSimulation(new double[]{5}, new int[]{0}, new boolean[1], + new double[1], new double[1], new int[0], new int[0], p); + assertTrue("single node converges", runToEnd(one) > 0); + // two nodes at the same place, joined by a self loop and a normal edge + ClusterForceSimulation two = new ClusterForceSimulation(new double[]{5, 5}, new int[]{0, 0}, + new boolean[2], new double[2], new double[2], new int[]{0, 0}, new int[]{0, 1}, p); + runToEnd(two); + assertFalse("no NaN", Double.isNaN(two.x[0]) || Double.isNaN(two.x[1])); + } +} diff --git a/modules/CommunityClusters/src/test/java/myrberger/communityclusters/LeidenTest.java b/modules/CommunityClusters/src/test/java/myrberger/communityclusters/LeidenTest.java new file mode 100644 index 000000000..f52c15b5a --- /dev/null +++ b/modules/CommunityClusters/src/test/java/myrberger/communityclusters/LeidenTest.java @@ -0,0 +1,149 @@ +package myrberger.communityclusters; + +import static org.junit.Assert.assertArrayEquals; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertTrue; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Random; +import org.junit.Test; + +public class LeidenTest { + + /** Random graph with planted communities of equal size. */ + private static final class Planted { + final int n; + final int[] truth; + final int[] src; + final int[] dst; + + Planted(int groups, int perGroup, double pIn, double pOut, long seed) { + n = groups * perGroup; + truth = new int[n]; + Random r = new Random(seed); + List edges = new ArrayList<>(); + for (int i = 0; i < n; i++) { + truth[i] = i / perGroup; + } + for (int i = 0; i < n; i++) { + for (int j = i + 1; j < n; j++) { + if (r.nextDouble() < (truth[i] == truth[j] ? pIn : pOut)) { + edges.add(new int[]{i, j}); + } + } + } + src = new int[edges.size()]; + dst = new int[edges.size()]; + for (int i = 0; i < src.length; i++) { + src[i] = edges.get(i)[0]; + dst[i] = edges.get(i)[1]; + } + } + } + + /** Newman modularity of a partition of an unweighted graph. */ + private static double modularity(int[] src, int[] dst, int[] comm) { + double m = src.length; + Map inside = new HashMap<>(); + Map total = new HashMap<>(); + for (int i = 0; i < src.length; i++) { + if (comm[src[i]] == comm[dst[i]]) { + inside.merge(comm[src[i]], 1.0, Double::sum); + } + total.merge(comm[src[i]], 1.0, Double::sum); + total.merge(comm[dst[i]], 1.0, Double::sum); + } + double q = 0; + for (Map.Entry e : total.entrySet()) { + double share = e.getValue() / (2 * m); + q += inside.getOrDefault(e.getKey(), 0.0) / m - share * share; + } + return q; + } + + private static int clusterCount(int[] comm) { + return Arrays.stream(comm).max().getAsInt() + 1; + } + + @Test + public void recoversPlantedCommunities() { + Planted g = new Planted(5, 40, 0.15, 0.005, 3); + int[] found = Leiden.run(g.n, g.src, g.dst, null, 1.0, 42); + assertEquals("number of clusters", 5, clusterCount(found)); + assertTrue("modularity at least that of the planted partition", + modularity(g.src, g.dst, found) >= modularity(g.src, g.dst, g.truth) - 0.005); + // every planted group should be dominated by a single detected cluster + int agree = 0; + for (int group = 0; group < 5; group++) { + Map counts = new HashMap<>(); + for (int i = 0; i < g.n; i++) { + if (g.truth[i] == group) { + counts.merge(found[i], 1, Integer::sum); + } + } + agree += counts.values().stream().max(Integer::compare).get(); + } + assertTrue("purity above 90%", agree > 0.9 * g.n); + } + + @Test + public void resultIsLocallyOptimal() { + Planted g = new Planted(10, 30, 0.15, 0.005, 3); + int[] found = Leiden.run(g.n, g.src, g.dst, null, 1.0, 1); + double q = modularity(g.src, g.dst, found); + int k = clusterCount(found); + for (int v = 0; v < g.n; v++) { + for (int c = 0; c <= k; c++) { + if (c == found[v]) { + continue; + } + int[] moved = found.clone(); + moved[v] = c; + assertTrue("moving node " + v + " to " + c + " must not improve modularity", + modularity(g.src, g.dst, moved) <= q + 1e-12); + } + } + } + + @Test + public void sameSeedGivesSameResult() { + Planted g = new Planted(5, 40, 0.15, 0.005, 3); + assertArrayEquals("same seed", Leiden.run(g.n, g.src, g.dst, null, 1.0, 7), + Leiden.run(g.n, g.src, g.dst, null, 1.0, 7)); + } + + @Test + public void higherResolutionGivesMoreClusters() { + Planted g = new Planted(8, 40, 0.15, 0.01, 5); + int low = clusterCount(Leiden.run(g.n, g.src, g.dst, null, 0.2, 1)); + int high = clusterCount(Leiden.run(g.n, g.src, g.dst, null, 4.0, 1)); + assertTrue("resolution 4 (" + high + " clusters) should split more than resolution 0.2 (" + low + ")", + high > low); + } + + @Test + public void nodesWithoutEdgesAreSingletons() { + assertArrayEquals("no edges", new int[]{0, 1, 2, 3}, Leiden.run(4, new int[0], new int[0], null, 1, 1)); + assertEquals("empty graph", 0, Leiden.run(0, new int[0], new int[0], null, 1, 1).length); + } + + @Test + public void twoTrianglesWithABridgeSplitInTwo() { + int[] src = {0, 1, 2, 3, 4, 5, 2}; + int[] dst = {1, 2, 0, 4, 5, 3, 3}; + assertArrayEquals("triangles", new int[]{0, 0, 0, 1, 1, 1}, Leiden.run(6, src, dst, null, 1, 1)); + } + + @Test + public void weightsAreUsedAndSelfLoopsAndNegativeWeightsIgnored() { + // 0-1 doubled, 2-3 strong, weak 1-2 bridge, a self loop and a negative edge + int[] src = {0, 0, 1, 2, 2, 3}; + int[] dst = {1, 1, 2, 3, 2, 2}; + double[] w = {1, 1, 0.1, 1, 5, -1}; + assertArrayEquals("weighted", new int[]{0, 0, 1, 1}, Leiden.run(4, src, dst, w, 1, 1)); + } +} diff --git a/pom.xml b/pom.xml index e0514a313..ef18dec4a 100644 --- a/pom.xml +++ b/pom.xml @@ -340,6 +340,11 @@ modules/GephiAI + + + + + modules/CommunityClusters