import 'dart:async'; import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; class MyExampleWidget extends StatefulWidget { @override _MyExampleWidgetState createState() => _MyExampleWidgetState(); } class _MyExampleWidgetState extends State { Graph graph; @override void initState() { super.initState(); var a = Node("A", 30, 30); var b = Node("B", 200, 200); var c = Node("C", 200, 100); var d = Node("D", 300, 300); var e = Node("E", 300, 100); var edges = [ Edge(a, b), Edge(a, c), Edge(b, d), Edge(b, e), Edge(e, c), ]; graph = Graph(); graph.nodes = [a, b, c, d, e]; graph.edges = edges; graph.assignRandomPositions(400, 650); graph.startLayout(); } @override Widget build(BuildContext context) { return GraphStackView(graph); /* return CanvasTouchDetector( builder: (context) { return CustomPaint( painter: MyPainter(context, graph), ); }, );*/ } } class MyPainter extends CustomPainter { final Graph graph; MyPainter(this.graph) : super(repaint: graph); @override void paint(Canvas canvas, Size size) { // Draw all the edges for (var edge in graph.edges) { var strokeWitdth = 2.5; if (edge.a.pressed || edge.b.pressed) { strokeWitdth *= 2; } canvas.drawLine( Offset(edge.a.x, edge.a.y), Offset(edge.b.x, edge.b.y), Paint() ..color = Colors.grey ..strokeWidth = strokeWitdth, /*onPanUpdate: (detail) { print('Edge ${edge.a.label} -> ${edge.b.label} Swiped'); },*/ ); } } @override bool shouldRepaint(CustomPainter oldDelegate) { return false; } } class Node { String label = ""; double x; double y; bool pressed = false; double forceX = 0.0; double forceY = 0.0; Node(this.label, this.x, this.y); @override String toString() => "Node{$label, $x, $y}"; } class Edge { Node a; Node b; Edge(this.a, this.b); } class Graph extends ChangeNotifier { List nodes = []; List edges = []; Map> _neighbours = {}; Map _nodeIndexes; void notify() { notifyListeners(); startLayout(); } 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.union(computeOverlappingNodes(n)).toList(); } 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; } } _neighbours[n.label] = _nodes; return nodes.union(computeOverlappingNodes(n)).toList(); } // These nodes aren't actually neighbours, but we don't want nodes to // ever overlap, so I'm making the ones that are close by neighbours Set computeOverlappingNodes(Node n) { var _nodes = {}; for (var i = 0; i < nodes.length; i++) { var node = nodes[i]; if (node.label == n.label) { continue; } var dx = node.x - n.x; var dy = node.y - n.y; var dist = sqrt((dx * dx) + (dy * dy)); if (dist <= 60) { // print('${node.label} and ${n.label} are too close - $dist'); _nodes.add(i); } } 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(); } Timer layoutTimer; void startLayout() { if (layoutTimer != null) { return; } const interval = Duration(milliseconds: 25); layoutTimer = Timer.periodic(interval, (Timer t) { bool shouldStop = updateGraphPositions(this); print("shouldStop $shouldStop"); if (shouldStop) { layoutTimer.cancel(); layoutTimer = null; } }); /* Timer(const Duration(seconds: 5), () { if (layoutTimer != null) { layoutTimer.cancel(); layoutTimer = null; } });*/ } } void main() => runApp(MyApp()); /// This Widget is the main application widget. class MyApp extends StatelessWidget { static const String _title = 'Graphs Experiments'; @override Widget build(BuildContext context) { return MaterialApp( title: _title, home: MyWidget(), ); } } class MyWidget extends StatelessWidget { MyWidget({Key key}) : super(key: key); @override Widget build(BuildContext context) { return Scaffold( body: Center( child: MyExampleWidget(), /*child: GraphView( children: [ Container(width: 50, height: 50, color: Colors.orange), Container(width: 50, height: 50, color: Colors.blue), Container(width: 50, height: 50, color: Colors.red), Container(width: 50, height: 50, color: Colors.yellow), ], ),*/ ), ); } } // 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]; // Skip Node which is current being controlled if (node.pressed) { continue; } 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; } class GraphRenderObjectParentData extends ContainerBoxParentData { double x = -1; double y = -1; GraphRenderObjectParentData(); } const maxX = 400; const maxY = 650; class GraphRenderBox extends RenderBox with ContainerRenderObjectMixin, RenderBoxContainerDefaultsMixin { @override void setupParentData(RenderBox child) { if (child.parentData is! GraphRenderObjectParentData) { child.parentData = GraphRenderObjectParentData(); } } @override double computeDistanceToActualBaseline(TextBaseline baseline) => defaultComputeDistanceToFirstActualBaseline(baseline); @override void performLayout() { print('performLayout'); size = const Size(400, 650); var random = Random(DateTime.now().millisecondsSinceEpoch); // Assign each of the children their value var child = firstChild; while (child != null) { child.layout(constraints, parentUsesSize: true); // child.size gives size final childParentData = child.parentData as GraphRenderObjectParentData; if (childParentData.x == -1 && childParentData.y == -1) { var x = random.nextInt(maxX).toDouble(); var y = random.nextInt(maxY).toDouble(); childParentData.x = x; childParentData.y = y; } childParentData.offset = Offset(childParentData.x, childParentData.y); print(childParentData.offset); child = childParentData.nextSibling; // FIXME: Do not let them intersect // Do not let them go over the max size // Computer a proper size } print('performLayout done'); } @override bool hitTestChildren(BoxHitTestResult result, {Offset position}) { return defaultHitTestChildren(result, position: position); } @override void paint(PaintingContext context, Offset offset) { // // Paint Edges // var child = firstChild; final childParentData = child.parentData as GraphRenderObjectParentData; var secondChild = childParentData.nextSibling; var secondChildParentData = secondChild.parentData as GraphRenderObjectParentData; var paint = Paint(); paint.color = Colors.grey; paint.strokeWidth = 3.0; context.canvas.drawLine( childParentData.offset.translate(child.size.width / 2, child.size.height), secondChildParentData.offset .translate(child.size.width / 2, child.size.height), paint, ); defaultPaint(context, offset); } } class GraphView extends MultiChildRenderObjectWidget { GraphView({ Key key, List children = const [], }) : super(key: key, children: children); @override GraphRenderBox createRenderObject(BuildContext context) { return GraphRenderBox(); } } // TODO: // 1. Get this to accept a builder function to draw the widgets (instead of them always being drawn) // 2. Implement a getEdges function // 3. Place them in some initial order? // 4. Update their positions based on // Maybe use CustomMultiChildLayout instead? class GraphStackView extends StatefulWidget { final Graph graph; GraphStackView(this.graph); @override _GraphStackViewState createState() => _GraphStackViewState(); } class _GraphStackViewState extends State { @override void initState() { super.initState(); widget.graph.addListener(() { setState(() {}); }); } @override Widget build(BuildContext context) { var children = []; children.add(CustomPaint(painter: MyPainter(widget.graph))); for (var node in widget.graph.nodes) { var w = Positioned( child: GestureDetector( child: NodeWidget(node), onPanStart: (details) { node.x = details.globalPosition.dx; node.y = details.globalPosition.dy; node.pressed = true; widget.graph.notify(); print("Pan start ${node.label} $details"); }, onPanEnd: (DragEndDetails details) { print("Pan end ${node.label} $details"); node.pressed = false; widget.graph.notify(); }, onPanUpdate: (details) { node.x = details.globalPosition.dx; node.y = details.globalPosition.dy; widget.graph.notify(); print("Pan update ${node.label} ${details.globalPosition}"); }, ), left: node.x - 25, top: node.y - 25, ); children.add(w); } return Stack( children: children, fit: StackFit.expand, ); } } class NodeWidget extends StatelessWidget { final Node node; NodeWidget(this.node); @override Widget build(BuildContext context) { return Column( children: [ Container( width: 50, height: 50, decoration: const BoxDecoration( color: Colors.orange, shape: BoxShape.circle, ), ), Text(node.label), ], crossAxisAlignment: CrossAxisAlignment.center, ); } }