package layout.panes; import java.util.List; import javafx.beans.property.ObjectProperty; import javafx.beans.property.ObjectPropertyBase; import javafx.collections.ListChangeListener; import javafx.geometry.Bounds; import javafx.geometry.Point2D; import javafx.scene.Node; import javafx.scene.layout.Pane; import layout.PhysLayout; import physics.Box2DSpringSimulation; import physics.Spring; import physics.Tether; /** * Use one node as the center and orient the other nodes around it in clockwise * order. This layout currently ignores its children's sizes. * * @author Christoph Burschka <christoph@burschka.de> */ public class WheelPane extends Pane implements PhysicalPane { public final ObjectProperty<Node> center; private final PhysLayout layout; private final Box2DSpringSimulation simulation; private double radius; private double strength = 50; private double spacing = 0; public WheelPane() { center = new CenterProperty(); layout = new PhysLayout(this); simulation = new Box2DSpringSimulation(layout); radius = Math.min(this.getWidth() * 0.5, this.getHeight() * 0.5); simulation.setFriction(2); } @Override protected void layoutChildren() { simulation.stopSimulation(); super.layoutChildren(); final Node c = center.get(); layout.clearAllMasses(); layout.clearAllConnections(); if (c != null) { layout.setMass(c, Double.POSITIVE_INFINITY); } final List<Node> managedChildren = getManagedChildren(); // TODO: Try not to recreate this on each layout pass. final Node[] children = getManagedChildren().stream().filter(e -> { return e != c; }).toArray(size -> { return new Node[size]; }); double diags[] = new double[children.length]; for (int i = 0; i < diags.length; i++) { final Bounds bounds = children[i].getBoundsInLocal(); diags[i] = Math.pow(Math.pow(bounds.getHeight(), 2) + Math.pow(bounds.getWidth(), 2), 0.5); } /** * Calculate the preferred radius of this wheel. The maximum of the * following three values is taken: - the minimum radius (defaults to 0) * - the circumference required to fit all outside nodes in a circle. - * the diameter required to fit the largest two nodes in line with the * center node. */ double m1 = 0, m2 = 0, s = 0; // Find the sum of all diagonals, and the largest two diagonals. for (double diag : diags) { s += diag; if (diag >= m2) { m1 = m2; m2 = diag; } else if (diag > m1) { m1 = diag; } } final Bounds bounds = c.getBoundsInLocal(); final double centerDiag = Math.pow(Math.pow(bounds.getHeight(), 2) + Math.pow(bounds.getWidth(), 2), 0.5); final double diameter = m1 + m2 + centerDiag + 2 * spacing; final double circumference = s + children.length * spacing; final double r = Math.max(radius, Math.max(diameter * 0.5, circumference * 0.5 / Math.PI)); /** * Connect the children by springs of the appropriate length. Every * (adjacent and non-adjacent) pair of surrounding nodes is connected by * a spring that matches the length of the chord between them. */ for (int _i = 0; _i < children.length; _i++) { double d = spacing; for (int j = 1; j < children.length; j++) { // The arc in proportion to the calculated circumference is half the endpoints' sizes plus all the space between them: final double arcSection = (d + (diags[_i] + diags[(_i + j) % children.length]) * 0.5) / circumference; // chord length on the unit circle is twice the sine of half the angle: final double chordLength = 2 * r * Math.sin(arcSection * Math.PI); layout.addConnection(children[_i], children[(_i + j) % children.length], new Spring(chordLength, strength)); d += diags[(_i + j) % children.length] + spacing; } if (c != null) { layout.addConnection(c, children[_i], new Spring(r, strength)); } else { // Without a center node, fix nodes to the center of the pane instead. layout.addTether(children[_i], new Tether(r, strength, Point2D.ZERO)); } } simulation.startSimulation(); } public final void setCenter(Node value) { center.set(value); } public final Node getCenter() { return center.get(); } @Override public Box2DSpringSimulation getSimulation() { return simulation; } @Override public void setStrength(double strength) { this.strength = strength; requestLayout(); } @Override public double getStrength() { return strength; } /** * Inner class tracking the pane's central node (see * javafx.layout.scene.layout.BorderPane). */ private final class CenterProperty extends ObjectPropertyBase<Node> { private boolean isBeingInvalidated; private Node oldValue = null; @Override public Object getBean() { return WheelPane.this; } @Override public String getName() { return "center"; } CenterProperty() { // Unset the center property if the central node is removed from children. getChildren().addListener((ListChangeListener.Change<? extends Node> c) -> { if (oldValue != null && !isBeingInvalidated) { while (c.next()) { if (c.wasRemoved() && c.getRemoved().contains(oldValue)) { oldValue = null; set(null); } } } }); } @Override protected void invalidated() { // Replace the node in the list of children. final List<Node> children = getChildren(); isBeingInvalidated = true; try { if (oldValue != null) { children.remove(oldValue); } final Node value = get(); this.oldValue = value; if (value != null) { children.add(value); } } finally { isBeingInvalidated = false; } } } public void setMinRadius(double radius) { this.radius = radius; requestLayout(); } public void setSpacing(double spacing) { this.spacing = spacing; requestLayout(); } }