diff --git a/diagram-util/src/main/java/com/powsybl/diagram/util/forcelayout/AbstractForceLayout.java b/diagram-util/src/main/java/com/powsybl/diagram/util/forcelayout/AbstractForceLayout.java new file mode 100644 index 000000000..a5c6aee46 --- /dev/null +++ b/diagram-util/src/main/java/com/powsybl/diagram/util/forcelayout/AbstractForceLayout.java @@ -0,0 +1,201 @@ +/** + Copyright (c) 2024, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * SPDX-License-Identifier: MPL-2.0 + */ +package com.powsybl.diagram.util.forcelayout; + +import org.jgrapht.Graph; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.PrintWriter; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.function.Function; + +/** + * @author Mathilde Grapin {@literal } + * @author Luma Zamarreño {@literal } + * @author José Antonio Marqués {@literal } + */ +public abstract class AbstractForceLayout { + private static final Logger LOGGER = LoggerFactory.getLogger(AbstractForceLayout.class); + + /** Deterministic randomness */ + private final Random random = new Random(3L); + + private static final int DEFAULT_MAX_STEPS = 1000; + private static final double DEFAULT_MIN_ENERGY_THRESHOLD = 0.001; + private static final double DEFAULT_DELTA_TIME = 1; + private int maxSteps; + private double minEnergyThreshold; + private double deltaTime; + /** Initial location for some nodes */ + private Map initialPoints = Collections.emptyMap(); + /** The location of these nodes should not be modified by the layout */ + private Set fixedNodes = Collections.emptySet(); + + private final Graph graph; + private final Map points = new LinkedHashMap<>(); + private final Set springs = new LinkedHashSet<>(); + + private boolean hasBeenExecuted = false; + + protected AbstractForceLayout(Graph graph) { + this.maxSteps = DEFAULT_MAX_STEPS; + this.minEnergyThreshold = DEFAULT_MIN_ENERGY_THRESHOLD; + this.deltaTime = DEFAULT_DELTA_TIME; + this.graph = Objects.requireNonNull(graph); + } + + public final void computePositions() { + execute(); + hasBeenExecuted = true; + } + + protected abstract void execute(); + + public AbstractForceLayout setMaxSteps(int maxSteps) { + this.maxSteps = maxSteps; + return this; + } + + public int getMaxSteps() { + return maxSteps; + } + + public AbstractForceLayout setMinEnergyThreshold(double minEnergyThreshold) { + this.minEnergyThreshold = minEnergyThreshold; + return this; + } + + public double getDeltaTime() { + return deltaTime; + } + + public AbstractForceLayout setDeltaTime(double deltaTime) { + this.deltaTime = deltaTime; + return this; + } + + public AbstractForceLayout setInitialPoints(Map initialPoints) { + this.initialPoints = Objects.requireNonNull(initialPoints); + return this; + } + + public AbstractForceLayout setFixedPoints(Map fixedPoints) { + this.initialPoints = Objects.requireNonNull(fixedPoints); + setFixedNodes(fixedPoints.keySet()); + return this; + } + + public AbstractForceLayout setFixedNodes(Set fixedNodes) { + this.fixedNodes = Objects.requireNonNull(fixedNodes); + return this; + } + + public Vector getStablePosition(V vertex) { + if (!hasBeenExecuted) { + LOGGER.warn("Force layout has not been executed yet"); + } + return points.getOrDefault(vertex, new Point(-1, -1)).getPosition(); + } + + public Map getPoints() { + return points; + } + + public Set getSprings() { + return springs; + } + + Set getFixedNodes() { + return fixedNodes; + } + + public void toSVG(Function tooltip, Path path) throws IOException { + try (Writer writer = Files.newBufferedWriter(path, StandardCharsets.UTF_8)) { + toSVG(tooltip, writer); + } + } + + public void toSVG(Function tooltip, Writer writer) { + if (!hasBeenExecuted) { + LOGGER.warn("Force layout has not been executed yet"); + return; + } + + BoundingBox boundingBox = BoundingBox.computeBoundingBox(points.values()); + Canvas canvas = new Canvas(boundingBox, 600, 10); + + PrintWriter printWriter = new PrintWriter(writer); + + printWriter.println(""); + printWriter.printf(Locale.US, "%n", canvas.getWidth(), canvas.getHeight()); + printWriter.println(""); + + points.forEach((vertex, point) -> point.toSVG(printWriter, canvas, tooltip, vertex)); + + for (Spring spring : springs) { + spring.toSVG(printWriter, canvas); + } + + printWriter.println(""); + + printWriter.close(); + } + + protected void initializePoints() { + for (V vertex : graph.vertexSet()) { + Point p; + if (initialPoints.containsKey(vertex)) { + Point pInitial = initialPoints.get(vertex); + p = new Point(pInitial.getPosition().getX(), pInitial.getPosition().getY(), graph.degreeOf(vertex)); + } else { + p = new Point(random.nextDouble(), random.nextDouble(), graph.degreeOf(vertex)); + } + points.put(vertex, p); + } + } + + protected void initializeSprings() { + for (E e : graph.edgeSet()) { + Point pointSource = points.get(graph.getEdgeSource(e)); + Point pointTarget = points.get(graph.getEdgeTarget(e)); + if (pointSource != pointTarget) { // no use in force layout to add loops + springs.add(new Spring(pointSource, pointTarget, graph.getEdgeWeight(e))); + } + } + } + + protected void updatePosition() { + // Optimisation hint: do not compute forces or update velocities for fixed nodes + // We have computed forces and velocities for all nodes, even for the fixed ones + // We can optimize calculations by ignoring fixed nodes in those calculations + // Here we only update the position for the nodes that do not have fixed positions + for (Map.Entry vertexPoint : points.entrySet()) { + if (fixedNodes.contains(vertexPoint.getKey())) { + continue; + } + Point point = vertexPoint.getValue(); + Vector position = point.getPosition().add(point.getVelocity().multiply(deltaTime)); + point.setPosition(position); + } + } + + protected boolean isStable() { + return points.values().stream().allMatch(p -> p.getEnergy() < minEnergyThreshold); + } +} diff --git a/diagram-util/src/main/java/com/powsybl/diagram/util/forcelayout/ForceAtlas2Layout.java b/diagram-util/src/main/java/com/powsybl/diagram/util/forcelayout/ForceAtlas2Layout.java new file mode 100644 index 000000000..6c2f74ce8 --- /dev/null +++ b/diagram-util/src/main/java/com/powsybl/diagram/util/forcelayout/ForceAtlas2Layout.java @@ -0,0 +1,293 @@ +/** + Copyright (c) 2024, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * SPDX-License-Identifier: MPL-2.0 + */ +package com.powsybl.diagram.util.forcelayout; + +import org.jgrapht.Graph; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.*; + +/** + * The following algorithm is an implementation of the ForceAtlas2 layout algorithm. + * The implementation is based on the paper ForceAtlas2, a Continuous Graph Layout Algorithm for Handy Network Visualization Designed for the Gephi Software + * + * @author Luma Zamarreño {@literal } + * @author José Antonio Marqués {@literal } + */ +public class ForceAtlas2Layout extends AbstractForceLayout { + private static final Logger LOGGER = LoggerFactory.getLogger(ForceAtlas2Layout.class); + private static final double DEFAULT_K_REPULSION = 10.0; + private static final int DEFAULT_REPULSION_MODEL = -1; + private static final int DEFAULT_EDGE_WEIGHT_INFLUENCE = 1; + private static final int DEFAULT_ATTRACTION_MODEL = 1; + private static final double DEFAULT_K_GRAVITY = 1.0; + private static final double DEFAULT_K_SPEED = 0.1; + private static final double DEFAULT_K_MAX_SPEED = 10.0; + private static final double DEFAULT_GLOBAL_SPEED_RATIO = 1.0; + private static final double DEFAULT_GLOBAL_SPEED_INCREMENT_FACTOR = 1.5; + private static final boolean DEFAULT_STRONG_GRAVITY_MODE = false; + + private double kRepulsion; + private int repulsionModel; + private int edgeWeightInfluence; + private int attractionModel; + private double kGravity; + private double kSpeed; + private double kMaxSpeed; + private double globalSpeedRatio; + private double globalSpeedIncrementFactor; + private boolean strongGravityMode; + private double globalSpeed = Double.NaN; + + public ForceAtlas2Layout(Graph graph) { + super(graph); + this.kRepulsion = DEFAULT_K_REPULSION; + this.repulsionModel = DEFAULT_REPULSION_MODEL; + this.edgeWeightInfluence = DEFAULT_EDGE_WEIGHT_INFLUENCE; + this.attractionModel = DEFAULT_ATTRACTION_MODEL; + this.kGravity = DEFAULT_K_GRAVITY; + this.kSpeed = DEFAULT_K_SPEED; + this.kMaxSpeed = DEFAULT_K_MAX_SPEED; + this.globalSpeedRatio = DEFAULT_GLOBAL_SPEED_RATIO; + this.globalSpeedIncrementFactor = DEFAULT_GLOBAL_SPEED_INCREMENT_FACTOR; + this.strongGravityMode = DEFAULT_STRONG_GRAVITY_MODE; + } + + public double getkRepulsion() { + return kRepulsion; + } + + public void setkRepulsion(double kRepulsion) { + this.kRepulsion = kRepulsion; + } + + public int getRepulsionModel() { + return repulsionModel; + } + + public void setRepulsionModel(int repulsionModel) { + this.repulsionModel = repulsionModel; + } + + public int getEdgeWeightInfluence() { + return edgeWeightInfluence; + } + + public void setEdgeWeightInfluence(int edgeWeightInfluence) { + this.edgeWeightInfluence = edgeWeightInfluence; + } + + public int getAttractionModel() { + return attractionModel; + } + + public void setAttractionModel(int attractionModel) { + this.attractionModel = attractionModel; + } + + public double getkGravity() { + return kGravity; + } + + public void setkGravity(double kGravity) { + this.kGravity = kGravity; + } + + public double getkSpeed() { + return kSpeed; + } + + public void setkSpeed(double kSpeed) { + this.kSpeed = kSpeed; + } + + public double getkMaxSpeed() { + return kMaxSpeed; + } + + public void setkMaxSpeed(double kMaxSpeed) { + this.kMaxSpeed = kMaxSpeed; + } + + public double getGlobalSpeedRatio() { + return globalSpeedRatio; + } + + public void setGlobalSpeedRatio(double globalSpeedRatio) { + this.globalSpeedRatio = globalSpeedRatio; + } + + public double getGlobalSpeedIncrementFactor() { + return globalSpeedIncrementFactor; + } + + public void setGlobalSpeedIncrementFactor(double globalSpeedIncrementFactor) { + this.globalSpeedIncrementFactor = globalSpeedIncrementFactor; + } + + public boolean isStrongGravityMode() { + return strongGravityMode; + } + + public void setStrongGravityMode(boolean strongGravityMode) { + this.strongGravityMode = strongGravityMode; + } + + public double getGlobalSpeed() { + return globalSpeed; + } + + public void setGlobalSpeed(double globalSpeed) { + this.globalSpeed = globalSpeed; + } + + @Override + public void execute() { + long start = System.nanoTime(); + + initializePoints(); + initializeSprings(); + + int i; + for (i = 0; i < getMaxSteps(); i++) { + // kRepulsion = scalingRatio + applyPointsRepulsionForces(kRepulsion, repulsionModel); + applySpringsAttractionForces(edgeWeightInfluence, attractionModel); + + if (strongGravityMode) { + strongGravity(kGravity); + } else { + gravity(kGravity); + } + + updateNodalAndGlobalVelocity(kSpeed, kMaxSpeed, globalSpeedRatio, globalSpeedIncrementFactor); + updatePosition(); + + if (isStable()) { + break; + } + } + + long elapsedTime = System.nanoTime() - start; + + LOGGER.info("Number of steps: {}", i); + LOGGER.info("Elapsed time: {}", elapsedTime / 1e9); + } + + private void applyPointsRepulsionForces(double kRepulsion, int repulsionModel) { + for (Point point : getPoints().values()) { + Vector p = point.getPosition(); + int degree = point.getDegree(); + for (Point otherPoint : getPoints().values()) { + if (!point.equals(otherPoint)) { + point.applyForce(repulsionForce(p, degree, otherPoint.getPosition(), otherPoint.getDegree(), repulsionModel, kRepulsion)); + } + } + } + } + + private Vector repulsionForce(Vector p1, int degree1, Vector p2, int degree2, int repulsionModel, double repulsion) { + Vector distance = p1.subtract(p2); + Vector direction = distance.normalize(); + return direction.multiply(repulsion * (degree1 + 1) * (degree2 + 1)).multiply(Math.pow(distance.magnitude(), repulsionModel)); + } + + private void applySpringsAttractionForces(int edgeWeightInfluence, int attractionModel) { + for (Spring spring : getSprings()) { + Point point1 = spring.getNode1(); + Point point2 = spring.getNode2(); + + Vector distance = point2.getPosition().subtract(point1.getPosition()); + double displacement = distance.magnitude(); + Vector direction = distance.normalize(); + + Vector force = direction.multiply(Math.pow(spring.getLength(), edgeWeightInfluence) * Math.pow(displacement, attractionModel)); + + point1.applyForce(force.multiply(1)); + point2.applyForce(force.multiply(-1)); + } + } + + private void gravity(double kGravity) { + Point point2 = new Point(0, 0); + for (Point point : getPoints().values()) { + Vector distance = point2.getPosition().subtract(point.getPosition()); + Vector direction = distance.normalize(); + + point.applyForce(direction.multiply(kGravity * (point.getDegree() + 1))); + } + } + + private void strongGravity(double kGravity) { + Point point2 = new Point(0, 0); + for (Point point : getPoints().values()) { + Vector distance = point2.getPosition().subtract(point.getPosition()); + double displacement = distance.magnitude(); + Vector direction = distance.normalize(); + + point.applyForce(direction.multiply(kGravity * (point.getDegree() + 1) * displacement)); + } + } + + private void updateNodalAndGlobalVelocity(double kSpeed, double kMaxSpeed, double globalSpeedRatio, double globalSpeedIncrementFactor) { + double nextGlobalSpeed = calculateGlobalSpeed(globalSpeedRatio, globalSpeedIncrementFactor); + for (Point point : getPoints().values()) { + double nodalSpeed = calculateNodalSpeed(point, kSpeed, nextGlobalSpeed); + Vector newVelocity = point.getForces().multiply(nodalSpeed); + if (newVelocity.magnitude() >= kMaxSpeed) { + newVelocity = newVelocity.normalize().multiply(kMaxSpeed); + } + point.setVelocity(newVelocity); + point.setPreviousForces(); + point.resetForces(); + } + this.globalSpeed = nextGlobalSpeed; + } + + private double calculateGlobalSwinging() { + double globalSwinging = 0; + for (Map.Entry vertexPoint : getPoints().entrySet()) { + if (getFixedNodes().contains(vertexPoint.getKey())) { + continue; + } + Point point = vertexPoint.getValue(); + globalSwinging += calculateNodalSwinging(point); + } + // Avoid potential division by zero + return globalSwinging == 0.0 ? Double.MIN_VALUE : globalSwinging; + } + + private static double calculateNodalSwinging(Point point) { + return (point.getDegree() + 1) * point.getForces().subtract(point.getPreviousForces()).magnitude(); + } + + private double calculateGlobalTraction() { + double globalTraction = 0.0; + for (Map.Entry vertexPoint : getPoints().entrySet()) { + if (getFixedNodes().contains(vertexPoint.getKey())) { + continue; + } + Point point = vertexPoint.getValue(); + globalTraction += (point.getDegree() + 1) * point.getForces().add(point.getPreviousForces()).magnitude() * 0.5; + } + return globalTraction; + } + + private double calculateGlobalSpeed(double globalSpeedRatio, double globalSpeedIncrementFactor) { + double nextGlobalSpeed = globalSpeedRatio * calculateGlobalTraction() / calculateGlobalSwinging(); + if (!Double.isNaN(this.globalSpeed) && nextGlobalSpeed > globalSpeedIncrementFactor * this.globalSpeed) { + nextGlobalSpeed = globalSpeedIncrementFactor * this.globalSpeed; + } + return nextGlobalSpeed; + } + + private static double calculateNodalSpeed(Point point, double kSpeed, double globalSpeed) { + return kSpeed * globalSpeed / (1 + globalSpeed * Math.sqrt(calculateNodalSwinging(point))); + } +} diff --git a/diagram-util/src/main/java/com/powsybl/diagram/util/forcelayout/ForceLayout.java b/diagram-util/src/main/java/com/powsybl/diagram/util/forcelayout/ForceLayoutSpringy.java similarity index 50% rename from diagram-util/src/main/java/com/powsybl/diagram/util/forcelayout/ForceLayout.java rename to diagram-util/src/main/java/com/powsybl/diagram/util/forcelayout/ForceLayoutSpringy.java index 15a9fdfac..7534be84f 100644 --- a/diagram-util/src/main/java/com/powsybl/diagram/util/forcelayout/ForceLayout.java +++ b/diagram-util/src/main/java/com/powsybl/diagram/util/forcelayout/ForceLayoutSpringy.java @@ -31,142 +31,57 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -import java.io.IOException; -import java.io.PrintWriter; -import java.io.Writer; -import java.nio.charset.StandardCharsets; -import java.nio.file.Files; -import java.nio.file.Path; -import java.util.*; -import java.util.function.Function; - /** * The following algorithm is a force layout algorithm. * It seeks to place the nodes of a graph in such a way that the nodes are well spaced and that there are no unnecessary crossings. * The algorithm uses an analogy with physics where the nodes of the graph are particles with mass and the edges are springs. * Force calculations are used to place the nodes. * - * The algorithm is taken from: https://github.com/dhotson/springy + * The algorithm is taken from: Springy. A force directed graph layout algorithm in JavaScript * * @author Mathilde Grapin {@literal } */ -public class ForceLayout { - private static final Logger LOGGER = LoggerFactory.getLogger(ForceLayout.class); - - /** Deterministic randomness */ - private final Random random = new Random(3L); - - private static final int DEFAULT_MAX_STEPS = 1000; - private static final double DEFAULT_MIN_ENERGY_THRESHOLD = 0.001; - private static final double DEFAULT_DELTA_TIME = 1; +public class ForceLayoutSpringy extends AbstractForceLayout { + private static final Logger LOGGER = LoggerFactory.getLogger(ForceLayoutSpringy.class); private static final double DEFAULT_REPULSION = 800.0; private static final double DEFAULT_FRICTION = 500; private static final double DEFAULT_MAX_SPEED = 100; /** Spring repulsion is disabled by default */ private static final double DEFAULT_SPRING_REPULSION_FACTOR = 0.0; - - private int maxSteps; - private double minEnergyThreshold; - private double deltaTime; private double repulsion; private double friction; private double maxSpeed; private double springRepulsionFactor; - /** Initial location for some nodes */ - private Map initialPoints = Collections.emptyMap(); - /** The location of these nodes should not be modified by the layout */ - private Set fixedNodes = Collections.emptySet(); - private final Graph graph; - private final Map points = new LinkedHashMap<>(); - private final Set springs = new LinkedHashSet<>(); - - private boolean hasBeenExecuted = false; - - public ForceLayout(Graph graph) { - this.maxSteps = DEFAULT_MAX_STEPS; - this.minEnergyThreshold = DEFAULT_MIN_ENERGY_THRESHOLD; - this.deltaTime = DEFAULT_DELTA_TIME; - this.repulsion = DEFAULT_REPULSION; + public ForceLayoutSpringy(Graph graph) { + super(graph); this.friction = DEFAULT_FRICTION; this.maxSpeed = DEFAULT_MAX_SPEED; + this.repulsion = DEFAULT_REPULSION; this.springRepulsionFactor = DEFAULT_SPRING_REPULSION_FACTOR; - this.graph = Objects.requireNonNull(graph); } - public ForceLayout setMaxSteps(int maxSteps) { - this.maxSteps = maxSteps; - return this; - } - - public ForceLayout setMinEnergyThreshold(double minEnergyThreshold) { - this.minEnergyThreshold = minEnergyThreshold; - return this; - } - - public ForceLayout setDeltaTime(double deltaTime) { - this.deltaTime = deltaTime; - return this; - } - - public ForceLayout setRepulsion(double repulsion) { + public ForceLayoutSpringy setRepulsion(double repulsion) { this.repulsion = repulsion; return this; } - public ForceLayout setFriction(double friction) { + public ForceLayoutSpringy setFriction(double friction) { this.friction = friction; return this; } - public ForceLayout setMaxSpeed(double maxSpeed) { + public ForceLayoutSpringy setMaxSpeed(double maxSpeed) { this.maxSpeed = maxSpeed; return this; } - public ForceLayout setSpringRepulsionFactor(double springRepulsionFactor) { + public ForceLayoutSpringy setSpringRepulsionFactor(double springRepulsionFactor) { this.springRepulsionFactor = springRepulsionFactor; return this; } - public ForceLayout setInitialPoints(Map initialPoints) { - this.initialPoints = Objects.requireNonNull(initialPoints); - return this; - } - - public ForceLayout setFixedPoints(Map fixedPoints) { - this.initialPoints = Objects.requireNonNull(fixedPoints); - setFixedNodes(fixedPoints.keySet()); - return this; - } - - public ForceLayout setFixedNodes(Set fixedNodes) { - this.fixedNodes = Objects.requireNonNull(fixedNodes); - return this; - } - - private void initializePoints() { - for (V vertex : graph.vertexSet()) { - Point p; - if (initialPoints.containsKey(vertex)) { - p = initialPoints.get(vertex); - } else { - p = new Point(random.nextDouble(), random.nextDouble()); - } - points.put(vertex, p); - } - } - - private void initializeSprings() { - for (E e : graph.edgeSet()) { - Point pointSource = points.get(graph.getEdgeSource(e)); - Point pointTarget = points.get(graph.getEdgeTarget(e)); - if (pointSource != pointTarget) { // no use in force layout to add loops - springs.add(new Spring(pointSource, pointTarget, graph.getEdgeWeight(e))); - } - } - } - + @Override public void execute() { long start = System.nanoTime(); @@ -174,7 +89,7 @@ public void execute() { initializeSprings(); int i; - for (i = 0; i < maxSteps; i++) { + for (i = 0; i < getMaxSteps(); i++) { applyCoulombsLawToPoints(); if (springRepulsionFactor != 0.0) { applyCoulombsLawToSprings(); @@ -189,8 +104,6 @@ public void execute() { } } - hasBeenExecuted = true; - long elapsedTime = System.nanoTime() - start; LOGGER.info("Number of steps: {}", i); @@ -204,9 +117,9 @@ private Vector coulombsForce(Vector p1, Vector p2, double repulsion) { } private void applyCoulombsLawToPoints() { - for (Point point : points.values()) { + for (Point point : getPoints().values()) { Vector p = point.getPosition(); - for (Point otherPoint : points.values()) { + for (Point otherPoint : getPoints().values()) { if (!point.equals(otherPoint)) { point.applyForce(coulombsForce(p, otherPoint.getPosition(), repulsion)); } @@ -215,9 +128,9 @@ private void applyCoulombsLawToPoints() { } private void applyCoulombsLawToSprings() { - for (Point point : points.values()) { + for (Point point : getPoints().values()) { Vector p = point.getPosition(); - for (Spring spring : springs) { + for (Spring spring : getSprings()) { Point n1 = spring.getNode1(); Point n2 = spring.getNode2(); if (!n1.equals(point) && !n2.equals(point)) { @@ -231,13 +144,13 @@ private void applyCoulombsLawToSprings() { } } } - for (Spring spring : springs) { + for (Spring spring : getSprings()) { Point n1 = spring.getNode1(); Point n2 = spring.getNode2(); Vector p1 = spring.getNode1().getPosition(); Vector p2 = spring.getNode2().getPosition(); Vector center = p1.add(p2.subtract(p1).multiply(0.5)); - for (Spring otherSpring : springs) { + for (Spring otherSpring : getSprings()) { if (!spring.equals(otherSpring)) { // Compute the repulsion force between centers of the springs Vector op1 = otherSpring.getNode1().getPosition(); @@ -254,7 +167,7 @@ private void applyCoulombsLawToSprings() { } private void applyHookesLaw() { - for (Spring spring : springs) { + for (Spring spring : getSprings()) { Point point1 = spring.getNode1(); Point point2 = spring.getNode2(); @@ -269,7 +182,7 @@ private void applyHookesLaw() { } private void attractToCenter() { - for (Point point : points.values()) { + for (Point point : getPoints().values()) { Vector direction = point.getPosition().multiply(-1); point.applyForce(direction.multiply(repulsion / 200.0)); @@ -277,8 +190,8 @@ private void attractToCenter() { } private void updateVelocity() { - for (Point point : points.values()) { - Vector newVelocity = point.getForces().multiply((1 - Math.exp(-deltaTime * friction / point.getMass())) / friction); + for (Point point : getPoints().values()) { + Vector newVelocity = point.getForces().multiply((1 - Math.exp(-getDeltaTime() * friction / point.getMass())) / friction); point.setVelocity(newVelocity); if (point.getVelocity().magnitude() > maxSpeed) { @@ -289,72 +202,4 @@ private void updateVelocity() { point.resetForces(); } } - - private void updatePosition() { - // TODO do not compute forces or update velocities for fixed nodes - // We have computed forces and velocities for all nodes, even for the fixed ones - // We can optimize calculations by ignoring fixed nodes in those calculations - // Here we only update the position for the nodes that do not have fixed positions - for (Map.Entry vertexPoint : points.entrySet()) { - if (fixedNodes.contains(vertexPoint.getKey())) { - continue; - } - Point point = vertexPoint.getValue(); - Vector position = point.getPosition().add(point.getVelocity().multiply(deltaTime)); - point.setPosition(position); - } - } - - private boolean isStable() { - return points.values().stream().allMatch(p -> p.getEnergy() < minEnergyThreshold); - } - - public Vector getStablePosition(V vertex) { - if (!hasBeenExecuted) { - LOGGER.warn("Force layout has not been executed yet"); - } - return points.getOrDefault(vertex, new Point(-1, -1)).getPosition(); - } - - public Set getSprings() { - return springs; - } - - public void toSVG(Function tooltip, Path path) throws IOException { - try (Writer writer = Files.newBufferedWriter(path, StandardCharsets.UTF_8)) { - toSVG(tooltip, writer); - } - } - - public void toSVG(Function tooltip, Writer writer) { - if (!hasBeenExecuted) { - LOGGER.warn("Force layout has not been executed yet"); - return; - } - - BoundingBox boundingBox = BoundingBox.computeBoundingBox(points.values()); - Canvas canvas = new Canvas(boundingBox, 600, 10); - - PrintWriter printWriter = new PrintWriter(writer); - - printWriter.println(""); - printWriter.printf(Locale.US, "%n", canvas.getWidth(), canvas.getHeight()); - printWriter.println(""); - - points.forEach((vertex, point) -> point.toSVG(printWriter, canvas, tooltip, vertex)); - - for (Spring spring : springs) { - spring.toSVG(printWriter, canvas); - } - - printWriter.println(""); - - printWriter.close(); - } - } diff --git a/diagram-util/src/main/java/com/powsybl/diagram/util/forcelayout/Point.java b/diagram-util/src/main/java/com/powsybl/diagram/util/forcelayout/Point.java index 950a7093e..b09dae9c7 100644 --- a/diagram-util/src/main/java/com/powsybl/diagram/util/forcelayout/Point.java +++ b/diagram-util/src/main/java/com/powsybl/diagram/util/forcelayout/Point.java @@ -20,16 +20,28 @@ public class Point { private Vector velocity; private Vector forces; private final double mass; + private Vector previousForces; + private final int degree; public Point(double x, double y) { - this(x, y, DEFAULT_MASS); + this(x, y, DEFAULT_MASS, 0); + } + + public Point(double x, double y, int degree) { + this(x, y, DEFAULT_MASS, degree); } public Point(double x, double y, double mass) { + this(x, y, mass, 0); + } + + public Point(double x, double y, double mass, int degree) { this.position = new Vector(x, y); this.velocity = new Vector(0, 0); + this.previousForces = new Vector(0, 0); this.forces = new Vector(0, 0); this.mass = mass; + this.degree = degree; } public void applyForce(Vector force) { @@ -44,6 +56,10 @@ public Vector getVelocity() { return velocity; } + public Vector getPreviousForces() { + return previousForces; + } + public Vector getForces() { return forces; } @@ -64,10 +80,18 @@ public double getMass() { return this.mass; } + public int getDegree() { + return this.degree; + } + public void resetForces() { this.forces = new Vector(0, 0); } + public void setPreviousForces() { + this.previousForces = new Vector(forces.getX(), forces.getY()); + } + public void toSVG(PrintWriter printWriter, Canvas canvas, Function tooltip, V vertex) { printWriter.println(""); diff --git a/network-area-diagram/src/main/java/com/powsybl/nad/NadParameters.java b/network-area-diagram/src/main/java/com/powsybl/nad/NadParameters.java index 8da8d10fc..e3bdee537 100644 --- a/network-area-diagram/src/main/java/com/powsybl/nad/NadParameters.java +++ b/network-area-diagram/src/main/java/com/powsybl/nad/NadParameters.java @@ -9,7 +9,7 @@ import com.powsybl.iidm.network.Network; import com.powsybl.nad.build.iidm.IntIdProvider; -import com.powsybl.nad.layout.BasicForceLayoutFactory; +import com.powsybl.nad.layout.BasicForceLayoutSpringyFactory; import com.powsybl.nad.layout.LayoutFactory; import com.powsybl.nad.layout.LayoutParameters; import com.powsybl.nad.svg.LabelProvider; @@ -28,7 +28,7 @@ public class NadParameters { private LayoutParameters layoutParameters = new LayoutParameters(); private StyleProviderFactory styleProviderFactory = TopologicalStyleProvider::new; private LabelProviderFactory labelProviderFactory = DefaultLabelProvider::new; - private LayoutFactory layoutFactory = new BasicForceLayoutFactory(); + private LayoutFactory layoutFactory = new BasicForceLayoutSpringyFactory(); private IdProviderFactory idProviderFactory = IntIdProvider::new; public SvgParameters getSvgParameters() { diff --git a/network-area-diagram/src/main/java/com/powsybl/nad/layout/BasicForceLayout.java b/network-area-diagram/src/main/java/com/powsybl/nad/layout/AbstractBasicForceLayout.java similarity index 69% rename from network-area-diagram/src/main/java/com/powsybl/nad/layout/BasicForceLayout.java rename to network-area-diagram/src/main/java/com/powsybl/nad/layout/AbstractBasicForceLayout.java index bf58b9b35..bca6a4eb9 100644 --- a/network-area-diagram/src/main/java/com/powsybl/nad/layout/BasicForceLayout.java +++ b/network-area-diagram/src/main/java/com/powsybl/nad/layout/AbstractBasicForceLayout.java @@ -6,7 +6,7 @@ */ package com.powsybl.nad.layout; -import com.powsybl.diagram.util.forcelayout.ForceLayout; +import com.powsybl.diagram.util.forcelayout.AbstractForceLayout; import com.powsybl.diagram.util.forcelayout.Vector; import com.powsybl.nad.model.Edge; import com.powsybl.nad.model.Graph; @@ -19,16 +19,18 @@ /** * @author Florian Dupuy {@literal } + * @author Luma Zamarreño {@literal } */ -public class BasicForceLayout extends AbstractLayout { +public abstract class AbstractBasicForceLayout extends AbstractLayout { - private static final int SCALE = 100; + protected abstract int getScale(); + + protected abstract AbstractForceLayout getForceLayoutAlgorithm(org.jgrapht.Graph jgraphtGraph, LayoutParameters layoutParameters); @Override protected void nodesLayout(Graph graph, LayoutParameters layoutParameters) { org.jgrapht.Graph jgraphtGraph = graph.getJgraphtGraph(layoutParameters.isTextNodesForceLayout()); - ForceLayout forceLayout = new ForceLayout<>(jgraphtGraph); - forceLayout.setSpringRepulsionFactor(layoutParameters.getSpringRepulsionFactorForceLayout()); + AbstractForceLayout forceLayout = getForceLayoutAlgorithm(jgraphtGraph, layoutParameters); forceLayout.setMaxSteps(layoutParameters.getMaxSteps()); setInitialPositions(forceLayout, graph); @@ -38,11 +40,12 @@ protected void nodesLayout(Graph graph, LayoutParameters layoutParameters) { .collect(Collectors.toSet()); forceLayout.setFixedNodes(fixedNodes); - forceLayout.execute(); + forceLayout.computePositions(); + int scale = getScale(); jgraphtGraph.vertexSet().forEach(node -> { Vector p = forceLayout.getStablePosition(node); - node.setPosition(SCALE * p.getX(), SCALE * p.getY()); + node.setPosition(scale * p.getX(), scale * p.getY()); }); if (!layoutParameters.isTextNodesForceLayout()) { @@ -50,15 +53,16 @@ protected void nodesLayout(Graph graph, LayoutParameters layoutParameters) { } } - private void setInitialPositions(ForceLayout forceLayout, Graph graph) { + private void setInitialPositions(AbstractForceLayout forceLayout, Graph graph) { + int scale = getScale(); Map initialPoints = getInitialNodePositions().entrySet().stream() // Only accept positions for nodes in the graph .filter(nodePosition -> graph.getNode(nodePosition.getKey()).isPresent()) .collect(Collectors.toMap( nodePosition -> graph.getNode(nodePosition.getKey()).orElseThrow(), nodePosition -> new com.powsybl.diagram.util.forcelayout.Point( - nodePosition.getValue().getX() / SCALE, - nodePosition.getValue().getY() / SCALE) + nodePosition.getValue().getX() / scale, + nodePosition.getValue().getY() / scale) )); forceLayout.setInitialPoints(initialPoints); } diff --git a/network-area-diagram/src/main/java/com/powsybl/nad/layout/BasicForceAtlas2Layout.java b/network-area-diagram/src/main/java/com/powsybl/nad/layout/BasicForceAtlas2Layout.java new file mode 100644 index 000000000..9abc2af37 --- /dev/null +++ b/network-area-diagram/src/main/java/com/powsybl/nad/layout/BasicForceAtlas2Layout.java @@ -0,0 +1,34 @@ +/** + Copyright (c) 2024, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * SPDX-License-Identifier: MPL-2.0 + */ +package com.powsybl.nad.layout; + +import com.powsybl.diagram.util.forcelayout.AbstractForceLayout; +import com.powsybl.diagram.util.forcelayout.ForceAtlas2Layout; +import com.powsybl.nad.model.Edge; +import com.powsybl.nad.model.Node; + +/** + * @author Luma Zamarreño {@literal } + * @author José Antonio Marqués {@literal } + */ +public class BasicForceAtlas2Layout extends AbstractBasicForceLayout { + + private static final int SCALE = 20; + + @Override + protected int getScale() { + return SCALE; + } + + @Override + protected AbstractForceLayout getForceLayoutAlgorithm(org.jgrapht.Graph jgraphtGraph, LayoutParameters layoutParameters) { + // We could complete the ForceAtlas2 object created here + // setting additional parameters received through the layout parameters + return new ForceAtlas2Layout<>(jgraphtGraph); + } +} diff --git a/network-area-diagram/src/main/java/com/powsybl/nad/layout/BasicForceAtlas2LayoutFactory.java b/network-area-diagram/src/main/java/com/powsybl/nad/layout/BasicForceAtlas2LayoutFactory.java new file mode 100644 index 000000000..d916eb6a5 --- /dev/null +++ b/network-area-diagram/src/main/java/com/powsybl/nad/layout/BasicForceAtlas2LayoutFactory.java @@ -0,0 +1,19 @@ +/** + Copyright (c) 2024, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * SPDX-License-Identifier: MPL-2.0 + */ +package com.powsybl.nad.layout; + +/** + * @author Luma Zamarreño {@literal } + * @author José Antonio Marqués {@literal } + */ +public class BasicForceAtlas2LayoutFactory implements LayoutFactory { + @Override + public Layout create() { + return new BasicForceAtlas2Layout(); + } +} diff --git a/network-area-diagram/src/main/java/com/powsybl/nad/layout/BasicForceLayoutSpringy.java b/network-area-diagram/src/main/java/com/powsybl/nad/layout/BasicForceLayoutSpringy.java new file mode 100644 index 000000000..74feaaf0a --- /dev/null +++ b/network-area-diagram/src/main/java/com/powsybl/nad/layout/BasicForceLayoutSpringy.java @@ -0,0 +1,33 @@ +/** + * Copyright (c) 2021, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + */ +package com.powsybl.nad.layout; + +import com.powsybl.diagram.util.forcelayout.AbstractForceLayout; +import com.powsybl.diagram.util.forcelayout.ForceLayoutSpringy; +import com.powsybl.nad.model.Edge; +import com.powsybl.nad.model.Node; + +/** + * @author Florian Dupuy {@literal } + */ +public class BasicForceLayoutSpringy extends AbstractBasicForceLayout { + + private static final int SCALE = 100; + + @Override + protected int getScale() { + return SCALE; + } + + @Override + protected AbstractForceLayout getForceLayoutAlgorithm(org.jgrapht.Graph jgraphtGraph, LayoutParameters layoutParameters) { + ForceLayoutSpringy forceLayout = new ForceLayoutSpringy<>(jgraphtGraph); + forceLayout.setSpringRepulsionFactor(layoutParameters.getSpringRepulsionFactorForceLayout()); + return forceLayout; + } + +} diff --git a/network-area-diagram/src/main/java/com/powsybl/nad/layout/BasicForceLayoutFactory.java b/network-area-diagram/src/main/java/com/powsybl/nad/layout/BasicForceLayoutSpringyFactory.java similarity index 78% rename from network-area-diagram/src/main/java/com/powsybl/nad/layout/BasicForceLayoutFactory.java rename to network-area-diagram/src/main/java/com/powsybl/nad/layout/BasicForceLayoutSpringyFactory.java index 045d8013a..7cbdfbb1b 100644 --- a/network-area-diagram/src/main/java/com/powsybl/nad/layout/BasicForceLayoutFactory.java +++ b/network-area-diagram/src/main/java/com/powsybl/nad/layout/BasicForceLayoutSpringyFactory.java @@ -9,9 +9,9 @@ /** * @author Florian Dupuy {@literal } */ -public class BasicForceLayoutFactory implements LayoutFactory { +public class BasicForceLayoutSpringyFactory implements LayoutFactory { @Override public Layout create() { - return new BasicForceLayout(); + return new BasicForceLayoutSpringy(); } } diff --git a/network-area-diagram/src/test/java/com/powsybl/nad/AbstractTest.java b/network-area-diagram/src/test/java/com/powsybl/nad/AbstractTest.java index 6ad3d5768..edcfff6be 100644 --- a/network-area-diagram/src/test/java/com/powsybl/nad/AbstractTest.java +++ b/network-area-diagram/src/test/java/com/powsybl/nad/AbstractTest.java @@ -11,7 +11,8 @@ import com.powsybl.iidm.network.VoltageLevel; import com.powsybl.nad.build.iidm.NetworkGraphBuilder; import com.powsybl.nad.build.iidm.VoltageLevelFilter; -import com.powsybl.nad.layout.BasicForceLayout; +import com.powsybl.nad.layout.BasicForceLayoutSpringyFactory; +import com.powsybl.nad.layout.LayoutFactory; import com.powsybl.nad.layout.LayoutParameters; import com.powsybl.nad.model.Graph; import com.powsybl.nad.svg.LabelProvider; @@ -34,6 +35,7 @@ public abstract class AbstractTest { protected boolean debugSvg = false; protected boolean overrideTestReferences = false; + protected LayoutFactory defaultLayoutFactory = new BasicForceLayoutSpringyFactory(); private SvgParameters svgParameters; @@ -44,12 +46,20 @@ public abstract class AbstractTest { protected abstract LabelProvider getLabelProvider(Network network); protected String generateSvgString(Network network, String refFilename) { - return generateSvgString(network, VoltageLevelFilter.NO_FILTER, refFilename); + return generateSvgString(defaultLayoutFactory, network, VoltageLevelFilter.NO_FILTER, refFilename); + } + + protected String generateSvgString(LayoutFactory layoutFactory, Network network, String refFilename) { + return generateSvgString(layoutFactory, network, VoltageLevelFilter.NO_FILTER, refFilename); } protected String generateSvgString(Network network, Predicate voltageLevelFilter, String refFilename) { + return generateSvgString(defaultLayoutFactory, network, voltageLevelFilter, refFilename); + } + + protected String generateSvgString(LayoutFactory layoutFactory, Network network, Predicate voltageLevelFilter, String refFilename) { Graph graph = new NetworkGraphBuilder(network, voltageLevelFilter).buildGraph(); - new BasicForceLayout().run(graph, getLayoutParameters()); + layoutFactory.create().run(graph, getLayoutParameters()); StringWriter writer = new StringWriter(); new SvgWriter(getSvgParameters(), getStyleProvider(network), getLabelProvider(network)).writeSvg(graph, writer); String svgString = writer.toString(); diff --git a/network-area-diagram/src/test/java/com/powsybl/nad/layout/ForceAtlas2LayoutTest.java b/network-area-diagram/src/test/java/com/powsybl/nad/layout/ForceAtlas2LayoutTest.java new file mode 100644 index 000000000..2f6495180 --- /dev/null +++ b/network-area-diagram/src/test/java/com/powsybl/nad/layout/ForceAtlas2LayoutTest.java @@ -0,0 +1,52 @@ +/** + Copyright (c) 2024, RTE (http://www.rte-france.com) + * This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. + * SPDX-License-Identifier: MPL-2.0 + */ +package com.powsybl.nad.layout; + +import com.powsybl.iidm.network.Network; +import com.powsybl.nad.AbstractTest; +import com.powsybl.nad.svg.LabelProvider; +import com.powsybl.nad.svg.StyleProvider; +import com.powsybl.nad.svg.SvgParameters; +import com.powsybl.nad.svg.iidm.DefaultLabelProvider; +import com.powsybl.nad.svg.iidm.NominalVoltageStyleProvider; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +/** + * @author Luma Zamarreño {@literal } + * @author José Antonio Marqués {@literal } + */ +class ForceAtlas2LayoutTest extends AbstractTest { + + @BeforeEach + void setup() { + setLayoutParameters(new LayoutParameters().setTextNodesForceLayout(false)); + setSvgParameters(new SvgParameters() + .setInsertNameDesc(false) + .setSvgWidthAndHeightAdded(false)); + } + + @Override + protected StyleProvider getStyleProvider(Network network) { + return new NominalVoltageStyleProvider(network); + } + + @Override + protected LabelProvider getLabelProvider(Network network) { + return new DefaultLabelProvider(network, getSvgParameters()); + } + + @Test + void testDiamond() { + assertEquals( + toString("/diamond-atlas2.svg"), + generateSvgString(new BasicForceAtlas2LayoutFactory(), LayoutNetworkFactory.createDiamond(), "/diamond-atlas2.svg")); + } +} diff --git a/network-area-diagram/src/test/java/com/powsybl/nad/layout/ForceLayoutTest.java b/network-area-diagram/src/test/java/com/powsybl/nad/layout/ForceLayoutSpringyTest.java similarity index 97% rename from network-area-diagram/src/test/java/com/powsybl/nad/layout/ForceLayoutTest.java rename to network-area-diagram/src/test/java/com/powsybl/nad/layout/ForceLayoutSpringyTest.java index cb3356d64..d0a2ed0b9 100644 --- a/network-area-diagram/src/test/java/com/powsybl/nad/layout/ForceLayoutTest.java +++ b/network-area-diagram/src/test/java/com/powsybl/nad/layout/ForceLayoutSpringyTest.java @@ -21,7 +21,7 @@ /** * @author Luma Zamarreno {@literal } */ -class ForceLayoutTest extends AbstractTest { +class ForceLayoutSpringyTest extends AbstractTest { @BeforeEach void setup() { diff --git a/network-area-diagram/src/test/java/com/powsybl/nad/layout/LayoutWithInitialPositionsTest.java b/network-area-diagram/src/test/java/com/powsybl/nad/layout/LayoutWithInitialPositionsTest.java index f5e2e15f8..53f3006d9 100644 --- a/network-area-diagram/src/test/java/com/powsybl/nad/layout/LayoutWithInitialPositionsTest.java +++ b/network-area-diagram/src/test/java/com/powsybl/nad/layout/LayoutWithInitialPositionsTest.java @@ -150,7 +150,7 @@ private Map layoutResult(Network network, Map fixedNodePositions, Predicate voltageLevelFilter ) { - LayoutFactory delegateLayoutFactory = new BasicForceLayoutFactory(); + LayoutFactory delegateLayoutFactory = new BasicForceLayoutSpringyFactory(); PositionsLayoutFactory positionsLayoutFactory = new PositionsLayoutFactory( delegateLayoutFactory, initialNodePositions, diff --git a/network-area-diagram/src/test/resources/diamond-atlas2.svg b/network-area-diagram/src/test/resources/diamond-atlas2.svg new file mode 100644 index 000000000..181174fa9 --- /dev/null +++ b/network-area-diagram/src/test/resources/diamond-atlas2.svg @@ -0,0 +1,800 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
A 230
+ + + + + +
+
+
kV / °
+
+
+ +
+
A 400
+ + + + + +
+
+
kV / °
+
+
+ +
+
B 230
+ + + + + +
+
+
kV / °
+
+
+ +
+
C 20
+ + + + + +
+
+
kV / °
+
+
+ +
+
C 230
+ + + + + +
+
+
kV / °
+
+
+ +
+
C 66
+ + + + + +
+
+
kV / °
+
+
+ +
+
D 10
+ + + + + +
+
+
kV / °
+
+
+ +
+
D 66
+ + + + + +
+
+
kV / °
+
+
+ +
+
E 10
+ + + + + +
+
+
kV / °
+
+
+ +
+
F 10
+ + + + + +
+
+
kV / °
+
+
+ +
+
G 10
+ + + + + +
+
+
kV / °
+
+
+ +
+
H 10
+ + + + + +
+
+
kV / °
+
+
+ +
+
I 10
+ + + + + +
+
+
kV / °
+
+
+ +
+
J 10
+ + + + + +
+
+
kV / °
+
+
+ +
+
K 10
+ + + + + +
+
+
kV / °
+
+
+
+