From e09ea4313bcfe61aca5fadd7de70feca1f1e0622 Mon Sep 17 00:00:00 2001 From: Vishesh Handa Date: Mon, 3 Aug 2020 00:13:07 +0200 Subject: [PATCH] Graph Rendering: Implement a simple Force Directed Layout Based on the paper "Simple Algorithms for Network Visualization: A Tutorial" by Michael J. McGuffin. --- lib/main_graph.dart | 179 ++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 173 insertions(+), 6 deletions(-) diff --git a/lib/main_graph.dart b/lib/main_graph.dart index 90b94a33..7da7b8ac 100644 --- a/lib/main_graph.dart +++ b/lib/main_graph.dart @@ -1,3 +1,6 @@ +import 'dart:async'; +import 'dart:math'; + import 'package:flutter/material.dart'; import 'package:touchable/touchable.dart'; @@ -31,6 +34,19 @@ class _MyExampleWidgetState extends State { graph = Graph(); graph.nodes = [a, b, c, d, e]; graph.edges = edges; + + graph.assignRandomPositions(400, 650); + + const interval = Duration(milliseconds: 25); + bool shouldStop = false; + var timer = Timer.periodic(interval, (Timer t) { + if (shouldStop) { + return; + } + shouldStop = updateGraphPositions(graph); + }); + + Timer(const Duration(seconds: 5), () => timer.cancel()); } @override @@ -54,7 +70,6 @@ class MyPainter extends CustomPainter { @override void paint(Canvas canvas, Size size) { - print("Paint"); var myCanvas = TouchyCanvas(context, canvas); // Draw all the edges @@ -75,7 +90,7 @@ class MyPainter extends CustomPainter { for (var node in graph.nodes) { myCanvas.drawCircle( Offset(node.x, node.y), - 30, + 20, Paint()..color = Colors.orange, onPanStart: (tapdetail) { node.pressed = true; @@ -124,6 +139,9 @@ class Node { double y; bool pressed = false; + double forceX = 0.0; + double forceY = 0.0; + Node(this.label, this.x, this.y); @override @@ -141,9 +159,55 @@ class Graph extends ChangeNotifier { List nodes = []; List edges = []; + Map> _neighbours = {}; + Map _nodeIndexes; + void notify() { notifyListeners(); } + + List computeNeighbours(Node n) { + if (_nodeIndexes == null) { + _nodeIndexes = {}; + for (var i = 0; i < this.nodes.length; i++) { + var node = this.nodes[i]; + _nodeIndexes[node.label] = i; + } + } + + var _nodes = _neighbours[n.label]; + if (_nodes != null) { + return _nodes; + } + + var nodes = {}; + for (var edge in edges) { + if (edge.a.label == n.label) { + nodes.add(_nodeIndexes[edge.b.label]); + continue; + } + + if (edge.b.label == n.label) { + nodes.add(_nodeIndexes[edge.a.label]); + continue; + } + } + + _nodes = nodes.toList(); + _neighbours[n.label] = _nodes; + return _nodes; + } + + void assignRandomPositions(int maxX, int maxY) { + var random = Random(DateTime.now().millisecondsSinceEpoch); + + for (var node in nodes) { + node.x = random.nextInt(maxX).toDouble(); + node.y = random.nextInt(maxY).toDouble(); + } + + notifyListeners(); + } } void main() => runApp(MyApp()); @@ -167,11 +231,8 @@ class MyWidget extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar( - title: const Text('Sample Code'), - ), body: Container( - height: 500, + height: 700, width: 500, child: MyExampleWidget(), ), @@ -180,3 +241,109 @@ class MyWidget extends StatelessWidget { } // FIXME: Possibly use directed_graph library? + +// +// Basic Force Directed Layout +// + +const l = 150.0; // sping rest length +const k_r = 10000.0; // repulsive force constant +const k_s = 20; // spring constant +const delta_t = 0.005; // time step +const MAX_DISPLACEMENT_SQUARED = 16; +const min_movement = 1.0; + +bool updateGraphPositions(Graph g) { + var numNodes = g.nodes.length; + + // Initialize net forces + for (var i = 0; i < numNodes; i++) { + g.nodes[i].forceX = 0; + g.nodes[i].forceY = 0; + } + + for (var i1 = 0; i1 < numNodes - 1; i1++) { + var node1 = g.nodes[i1]; + + for (var i2 = i1 + 1; i2 < numNodes; i2++) { + var node2 = g.nodes[i2]; + var dx = node2.x - node1.x; + var dy = node2.y - node1.y; + + if (dx != 0 || dy != 0) { + var distSq = (dx * dx) + (dy * dy); + var distance = sqrt(distSq); + + var force = k_r / distSq; + var fx = force * dx / distance; + var fy = force * dy / distance; + + node1.forceX -= fx; + node1.forceY -= fy; + + node2.forceX += fx; + node2.forceY += fy; + } + } + } + + // Spring forces between adjacent pairs + for (var i1 = 0; i1 < numNodes; i1++) { + var node1 = g.nodes[i1]; + var node1Neighbours = g.computeNeighbours(node1); + + for (var j = 0; j < node1Neighbours.length; j++) { + var i2 = node1Neighbours[j]; + var node2 = g.nodes[i2]; + + if (i1 < i2) { + var dx = node2.x - node1.x; + var dy = node2.y - node1.y; + + if (dx != 0 || dy != 0) { + var distSq = (dx * dx) + (dy * dy); + var distance = sqrt(distSq); + + var force = k_s * (distance - l); + var fx = force * dx / distance; + var fy = force * dy / distance; + + node1.forceX += fx; + node1.forceY += fy; + + node2.forceX -= fx; + node2.forceY -= fy; + } + } + } + } + + // Update positions + var allBelowThreshold = true; + for (var i = 0; i < numNodes; i++) { + var node = g.nodes[i]; + + var dx = delta_t * node.forceX; + var dy = delta_t * node.forceY; + + var dispSq = (dx * dx) + (dy * dy); + if (dispSq > MAX_DISPLACEMENT_SQUARED) { + var s = sqrt(MAX_DISPLACEMENT_SQUARED / dispSq); + + dx *= s; + dy *= s; + } + + print('${node.label} $dx $dy'); + node.x += dx; + node.y += dy; + + if (dx.abs() > min_movement || dy.abs() > min_movement) { + allBelowThreshold = false; + } + } + print('------------------'); + + g.notify(); + return allBelowThreshold; +}