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