diff --git a/CHANGELOG.md b/CHANGELOG.md index 171fe57..19acefb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,7 +1,40 @@ +## 1.5.0 + +- **MAJOR UPDATE**: Added 5 new layout algorithms + - BalloonLayoutAlgorithm: Radial tree layout with circular child arrangements around parents + - CircleLayoutAlgorithm: Arranges nodes in circular formations with edge crossing reduction + - RadialTreeLayoutAlgorithm: Converts tree structures to polar coordinate system + - TidierTreeLayoutAlgorithm: Improved tree layout with better spacing and positioning + - MindmapAlgorithm: Specialized layout for mindmap-style distributions +- **NEW**: Node expand/collapse functionality with GraphViewController + - `collapseNode()`, `expandNode()`, `toggleNodeExpanded()` methods + - Hierarchical visibility control with animated transitions + - Initial collapsed state support via `setInitiallyCollapsedNodes()` +- **NEW**: Advanced animation system + - Smooth expand/collapse animations with customizable duration + - Node scaling and opacity transitions during state changes + - `toggleAnimationDuration` parameter for fine-tuning animations +- **NEW**: Enhanced GraphView.builder constructor + - `animated`: Enable/disable smooth animations (default: true) + - `autoZoomToFit`: Automatically zoom to fit all nodes on initialization + - `initialNode`: Jump to specific node on startup + - `panAnimationDuration`: Customizable camera movement timing + - `centerGraph`: Center the graph within viewport + - `controller`: GraphViewController for programmatic control +- **NEW**: Navigation and camera control features + - `jumpToNode()` and `animateToNode()` for programmatic navigation + - `zoomToFit()` for automatic viewport adjustment + - `resetView()` for returning to origin + - `forceRecalculation()` for layout updates +- **IMPROVED** TreeEdgeRenderer with curved/straight connection options +- **IMPROVED**: Better performance with caching for graphs +- **IMPROVED**: Sugiyama Algorithm with postStraighten and additional strategies + ## 1.2.0 - Resolved Overlaping for Sugiyama Algorithm (#56, #93, #87) - Added Enum for Coordinate Assignment in Sugiyama : DownRight, DownLeft, UpRight, UpLeft, Average(Default) + ## 1.1.1 - Fixed bug for SugiyamaAlgorithm where horizontal placement was overlapping @@ -69,4 +102,4 @@ ## 0.1.0 -- Initial release. +- Initial release. \ No newline at end of file diff --git a/LICENSE b/LICENSE index ce1304b..5814913 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2020 Nabil Mosharraf +Copyright (c) 2025 Nabil Mosharraf Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/README.md b/README.md index bd3fdd4..fdc4bbf 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,8 @@ GraphView =========== -Get it from +Get it from [![pub package](https://img.shields.io/pub/v/graphview.svg)](https://pub.dev/packages/graphview) -[![pub points](https://badges.bar/graphview/pub%20points)](https://pub.dev/packages/graphview/score) -[![popularity](https://badges.bar/graphview/popularity)](https://pub.dev/packages/graphview/score) -[![likes](https://badges.bar/graphview/likes)](https://pub.dev/packages/graphview/score) | +[![pub points](https://img.shields.io/pub/points/graphview/?color=2E8B57&label=pub%20points)](https://pub.dev/packages/graphview/score) Flutter GraphView is used to display data in graph structures. It can display Tree layout, Directed and Layered graph. Useful for Family Tree, Hierarchy View. @@ -14,31 +12,77 @@ Flutter GraphView is used to display data in graph structures. It can display Tr Overview ======== -The library is designed to support different graph layouts and currently works excellent with small graphs. +The library is designed to support different graph layouts and currently works excellent with small graphs. It now includes advanced features like node animations, expand/collapse functionality, and automatic camera positioning. You can have a look at the flutter web implementation here: http://graphview.surge.sh/ +Features +======== +- **Multiple Layout Algorithms**: Tree, Directed Graph, Layered Graph, Balloon, Circular, Radial, Tidier Tree, and Mindmap layouts +- **Node Animations**: Smooth expand/collapse animations with customizable duration +- **Interactive Navigation**: Jump to nodes, zoom to fit, auto-centering capabilities +- **Node Expand/Collapse**: Hierarchical node visibility control with animated transitions +- **Customizable Rendering**: Custom edge renderers, paint styling, and node builders +- **Touch Interactions**: Pan, zoom, and tap handling with InteractiveViewer integration + Layouts ====== ### Tree Uses Walker's algorithm with Buchheim's runtime improvements (`BuchheimWalkerAlgorithm` class). Supports different orientations. All you have to do is using the `BuchheimWalkerConfiguration.orientation` with either `ORIENTATION_LEFT_RIGHT`, `ORIENTATION_RIGHT_LEFT`, `ORIENTATION_TOP_BOTTOM` and `ORIENTATION_BOTTOM_TOP` (default). Furthermore parameters like sibling-, level-, subtree separation can be set. -Useful for: Family Tree, Hierarchy View, Flutter Widget Tree, +Useful for: Family Tree, Hierarchy View, Flutter Widget Tree + +### Tidier Tree +An improved tree layout algorithm (`TidierTreeLayoutAlgorithm` class) that provides better spacing and positioning for complex hierarchical structures. Supports all orientations and provides cleaner node arrangements. + +![alt Example](image/TidierTree.gif "Tidier Tree Animation") + +Useful for: Complex hierarchies, Organizational charts, Decision trees + ### Directed graph Directed graph drawing by simulating attraction/repulsion forces. For this the algorithm by Fruchterman and Reingold (`FruchtermanReingoldAlgorithm` class) was implemented. -Useful for: Social network, Mind Map, Cluster, Graphs, Intercity Road Network, +Useful for: Social network, Mind Map, Cluster, Graphs, Intercity Road Network ### Layered graph Algorithm from Sugiyama et al. for drawing multilayer graphs, taking advantage of the hierarchical structure of the graph (SugiyamaAlgorithm class). You can also set the parameters for node and level separation using the SugiyamaConfiguration. Supports different orientations. All you have to do is using the `SugiyamaConfiguration.orientation` with either `ORIENTATION_LEFT_RIGHT`, `ORIENTATION_RIGHT_LEFT`, `ORIENTATION_TOP_BOTTOM` and `ORIENTATION_BOTTOM_TOP` (default). Useful for: Hierarchical Graph which it can have weird edges/multiple paths +### Balloon Layout +A radial tree layout (`BalloonLayoutAlgorithm` class) that arranges child nodes in circular patterns around their parents. Creates balloon-like structures that are visually appealing for hierarchical data. + +![alt Example](image/BalloonLayout.gif "Balloon Layout Animation") + +Useful for: Mind maps, Radial trees, Circular hierarchies + +### Circular Layout +Arranges all nodes in a circle (`CircleLayoutAlgorithm` class). Includes edge crossing reduction algorithms for better readability. Supports automatic radius calculation and custom positioning. + +![alt Example](image/CircularLayout.gif "Circular Layout Animation") + +Useful for: Network visualization, Relationship diagrams, Cyclic structures + +### Radial Tree Layout +A tree layout that converts traditional tree structures into radial/polar coordinates (`RadialTreeLayoutAlgorithm` class). Nodes are positioned based on their distance from the root and angular position. + +![alt Example](image/RadialTree.gif "Radial Tree Animation") + +Useful for: Radial dendrograms, Phylogenetic trees, Sunburst-style hierarchies + +### Mindmap Layout +Specialized layout for mindmap-style visualizations (`MindmapAlgorithm` class) where child nodes are distributed on left and right sides of the root node. + +![alt Example](image/MindmapLayout.gif "Mindmap Layout Animation") + +Useful for: Mind maps, Concept maps, Brainstorming diagrams + Usage ====== +### Basic Setup Currently GraphView must be used together with a Zoom Engine like [InteractiveViewer](https://api.flutter.dev/flutter/widgets/InteractiveViewer-class.html). To change the zoom values just use the different attributes described in the InteractiveViewer class. To create a graph, we need to instantiate the `Graph` class. Then we need to pass the layout and also optional the edge renderer. @@ -61,6 +105,8 @@ class TreeViewPage extends StatefulWidget { } class _TreeViewPageState extends State { + final GraphViewController controller = GraphViewController(); + @override Widget build(BuildContext context) { return Scaffold( @@ -126,24 +172,18 @@ class _TreeViewPageState extends State { ], ), Expanded( - child: InteractiveViewer( - constrained: false, - boundaryMargin: EdgeInsets.all(100), - minScale: 0.01, - maxScale: 5.6, - child: GraphView( - graph: graph, - algorithm: BuchheimWalkerAlgorithm(builder, TreeEdgeRenderer(builder)), - paint: Paint() - ..color = Colors.green - ..strokeWidth = 1 - ..style = PaintingStyle.stroke, - builder: (Node node) { - // I can decide what widget should be shown here based on the id - var a = node.key.value as int; - return rectangleWidget(a); - }, - )), + child: GraphView.builder( + graph: graph, + algorithm: BuchheimWalkerAlgorithm(builder, TreeEdgeRenderer(builder)), + controller: controller, + animated: true, + autoZoomToFit: true, + builder: (Node node) { + // I can decide what widget should be shown here based on the id + var a = node.key.value as int; + return rectangleWidget(a); + }, + ), ), ], )); @@ -206,6 +246,136 @@ class _TreeViewPageState extends State { } } ``` + +### Advanced Features + +#### GraphView.builder +The enhanced `GraphView.builder` constructor provides additional capabilities: + +```dart +GraphView.builder( + graph: graph, + algorithm: BuchheimWalkerAlgorithm(config, TreeEdgeRenderer(config)), + controller: controller, + animated: true, // Enable smooth animations + autoZoomToFit: true, // Automatically zoom to fit all nodes + initialNode: ValueKey('startNode'), // Jump to specific node on init + panAnimationDuration: Duration(milliseconds: 600), + toggleAnimationDuration: Duration(milliseconds: 400), + centerGraph: true, // Center the graph in viewport + builder: (Node node) { + return YourCustomWidget(node); + }, +) +``` + +#### Node Expand/Collapse +Use the `GraphViewController` to manage node visibility: + +```dart +final controller = GraphViewController(); + +// Collapse a node (hide its children) +controller.collapseNode(graph, node, animate: true); + +// Expand a collapsed node +controller.expandNode(graph, node, animate: true); + +// Toggle collapse/expand state +controller.toggleNodeExpanded(graph, node, animate: true); + +// Check if node is collapsed +bool isCollapsed = controller.isNodeCollapsed(node); + +// Set initially collapsed nodes +controller.setInitiallyCollapsedNodes([node1, node2]); +``` + +#### Navigation and Camera Control +Navigate programmatically through the graph: + +```dart +// Jump to a specific node +controller.jumpToNode(ValueKey('nodeId')); + +// Animate to a node +controller.animateToNode(ValueKey('nodeId')); + +// Zoom to fit all visible nodes +controller.zoomToFit(); + +// Reset view to origin +controller.resetView(); + +// Force recalculation of layout +controller.forceRecalculation(); +``` + +### Algorithm Examples + +#### Balloon Layout +```dart +GraphView.builder( + graph: graph, + algorithm: BalloonLayoutAlgorithm( + BuchheimWalkerConfiguration(), + null + ), + builder: (node) => nodeWidget(node), +) +``` + +#### Circular Layout +```dart +GraphView.builder( + graph: graph, + algorithm: CircleLayoutAlgorithm( + CircleLayoutConfiguration( + radius: 200.0, + reduceEdgeCrossing: true, + ), + null + ), + builder: (node) => nodeWidget(node), +) +``` + +#### Radial Tree Layout +```dart +GraphView.builder( + graph: graph, + algorithm: RadialTreeLayoutAlgorithm( + BuchheimWalkerConfiguration(), + null + ), + builder: (node) => nodeWidget(node), +) +``` + +#### Tidier Tree Layout +```dart +GraphView.builder( + graph: graph, + algorithm: TidierTreeLayoutAlgorithm( + BuchheimWalkerConfiguration(), + TreeEdgeRenderer(config) + ), + builder: (node) => nodeWidget(node), +) +``` + +#### Mindmap Layout +```dart +GraphView.builder( + graph: graph, + algorithm: MindmapAlgorithm( + BuchheimWalkerConfiguration(), + MindmapEdgeRenderer(config) + ), + builder: (node) => nodeWidget(node), +) +``` + ### Using builder mechanism to build Nodes You can use any widget inside the node: @@ -236,7 +406,7 @@ getGraphView() { } ``` -### Color Edges individually +### Color Edges individually Add an additional parameter paint. Applicable for ArrowEdgeRenderer for now. ```dart @@ -259,9 +429,6 @@ You can focus on a specific node. This will allow scrolling to that node in the }, ``` -### Add drag nodes feature with animation -The code is there but not enabled yet due to dart null safety migration being more important - ### Extract info from any json to Graph Object Now its a bit easy to use Ids to extract info from any json to Graph Object @@ -329,6 +496,7 @@ getNodeText() { child: Text("Node ${n++}")); } ``` + Examples ======== #### Rooted Tree @@ -350,6 +518,27 @@ Examples #### Layered Graph ![alt Example](image/LayeredGraph.png "Layered Graph Example") +#### Balloon Layout +![alt Example](image/BalloonTreeLayout.gif "Balloon Layout Example") + +#### Circular Layout +![alt Example](image/CircleLayout.gif "Circular Layout Example") + +#### Radial Tree Layout +![alt Example](image/RadialTreeLayout.gif "Radial Tree Layout Example") + +#### Tidier Tree Layout +![alt Example](image/TidierTreeLayout.gif "Tidier Tree Layout Example") + +#### Mindmap Layout +![alt Example](image/MindMapLayout.gif "Mindmap Layout Example") + +#### Node Expand/Collapse Animation +![alt Example](image/NodeExpandCollapseAnimation.gif "Node Expand/Collapse Animation") + +#### Auto Navigation +![alt Example](image/AutoNavigationExample.gif "Auto Navigation Example") + Inspirations ======== This library is basically a dart representation of the excellent Android Library [GraphView](https://github.com/Team-Blox/GraphView) by Team-Blox @@ -361,10 +550,14 @@ Future Works - [x] Add nodeOnTap - [x] Add Layered Graph -- [] Use a builder pattern to draw items on demand. -- [] Animations -- [] Dynamic Node Position update for directed graph - +- [x] Animations +- [x] Dynamic Node Position update for directed graph +- [x] Node expand/collapse functionality +- [x] Auto-navigation and camera control +- [x] Multiple new layout algorithms (Balloon, Circular, Radial, Tidier, Mindmap) +- [ ] Finish Eiglsperger Algorithm +- [ ] Custom Edge Label Rendering +- [ ] Use a builder pattern to draw items on demand. License ======= diff --git a/example/.gitignore b/example/.gitignore index 9d532b1..7283898 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -32,7 +32,6 @@ /build/ # Web related -lib/generated_plugin_registrant.dart # Symbolication related app.*.symbols diff --git a/example/lib/algorithm_selector_graphview.dart b/example/lib/algorithm_selector_graphview.dart new file mode 100644 index 0000000..2fc5c50 --- /dev/null +++ b/example/lib/algorithm_selector_graphview.dart @@ -0,0 +1,324 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:graphview/GraphView.dart'; + +// Enum for algorithm types +enum LayoutAlgorithmType { + tidierTree, + buchheimWalker, + balloon, + radialTree, + circle, +} + +class AlgorithmSelectedVIewPage extends StatefulWidget { + @override + _TreeViewPageState createState() => _TreeViewPageState(); +} + +class _TreeViewPageState extends State with TickerProviderStateMixin { + GraphViewController _controller = GraphViewController(); + final Random r = Random(); + int nextNodeId = 1; + + // Algorithm selection + LayoutAlgorithmType _selectedAlgorithm = LayoutAlgorithmType.tidierTree; + Algorithm? _currentAlgorithm; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('Tree View - Multiple Algorithms'), + ), + body: Column( + mainAxisSize: MainAxisSize.max, + children: [ + // Algorithm selection dropdown + Container( + padding: EdgeInsets.all(8), + child: Row( + children: [ + Text('Layout Algorithm: '), + SizedBox(width: 8), + Expanded( + child: DropdownButton( + value: _selectedAlgorithm, + isExpanded: true, + onChanged: (LayoutAlgorithmType? newValue) { + if (newValue != null) { + setState(() { + _selectedAlgorithm = newValue; + _updateAlgorithm(); + }); + } + }, + items: LayoutAlgorithmType.values.map>((LayoutAlgorithmType value) { + return DropdownMenuItem( + value: value, + child: Text(_getAlgorithmDisplayName(value)), + ); + }).toList(), + ), + ), + ], + ), + ), + + // Configuration controls + Wrap( + children: [ + Container( + width: 100, + child: TextFormField( + initialValue: builder.siblingSeparation.toString(), + decoration: InputDecoration(labelText: 'Sibling Separation'), + onChanged: (text) { + builder.siblingSeparation = int.tryParse(text) ?? 100; + _updateAlgorithm(); + this.setState(() {}); + }, + ), + ), + Container( + width: 100, + child: TextFormField( + initialValue: builder.levelSeparation.toString(), + decoration: InputDecoration(labelText: 'Level Separation'), + onChanged: (text) { + builder.levelSeparation = int.tryParse(text) ?? 100; + _updateAlgorithm(); + this.setState(() {}); + }, + ), + ), + Container( + width: 100, + child: TextFormField( + initialValue: builder.subtreeSeparation.toString(), + decoration: InputDecoration(labelText: 'Subtree separation'), + onChanged: (text) { + builder.subtreeSeparation = int.tryParse(text) ?? 100; + _updateAlgorithm(); + this.setState(() {}); + }, + ), + ), + Container( + width: 100, + child: TextFormField( + initialValue: builder.orientation.toString(), + decoration: InputDecoration(labelText: 'Orientation'), + onChanged: (text) { + builder.orientation = int.tryParse(text) ?? 100; + _updateAlgorithm(); + this.setState(() {}); + }, + ), + ), + ElevatedButton( + onPressed: () { + final node12 = Node.Id(r.nextInt(100)); + var edge = graph.getNodeAtPosition(r.nextInt(graph.nodeCount())); + print(edge); + graph.addEdge(edge, node12); + setState(() {}); + }, + child: Text('Add'), + ), + ElevatedButton( + onPressed: _navigateToRandomNode, + child: Text('Go to Node $nextNodeId'), + ), + SizedBox(width: 8), + ElevatedButton( + onPressed: _resetView, + child: Text('Reset View'), + ), + SizedBox(width: 8,), + ElevatedButton( + onPressed: (){ + _controller.zoomToFit(); + }, + child: Text("Zoom to fit") + ) + ], + ), + + Expanded( + child: GraphView.builder( + controller: _controller, + graph: graph, + algorithm: _currentAlgorithm ?? TidierTreeLayoutAlgorithm(builder, null), + builder: (Node node) => Container( + padding: EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.lightBlue[100], + borderRadius: BorderRadius.circular(8), + ), + child: Text(node.key?.value.toString() ?? ''), + ), + ) + ), + ], + )); + } + + String _getAlgorithmDisplayName(LayoutAlgorithmType type) { + switch (type) { + case LayoutAlgorithmType.tidierTree: + return 'Tidier Tree Layout'; + case LayoutAlgorithmType.buchheimWalker: + return 'Buchheim Walker Tree Layout'; + case LayoutAlgorithmType.balloon: + return 'Balloon Layout'; + case LayoutAlgorithmType.radialTree: + return 'Radial Tree Layout'; + case LayoutAlgorithmType.circle: + return 'Circle Layout'; + } + } + + void _updateAlgorithm() { + switch (_selectedAlgorithm) { + case LayoutAlgorithmType.tidierTree: + _currentAlgorithm = TidierTreeLayoutAlgorithm(builder, null); + break; + case LayoutAlgorithmType.buchheimWalker: + _currentAlgorithm = BuchheimWalkerAlgorithm(builder, null); + break; + case LayoutAlgorithmType.balloon: + _currentAlgorithm = BalloonLayoutAlgorithm(builder, null); + break; + case LayoutAlgorithmType.radialTree: + _currentAlgorithm = RadialTreeLayoutAlgorithm(builder, null); + break; + case LayoutAlgorithmType.circle: + final circleConfig = CircleLayoutConfiguration( + radius: 200.0, + reduceEdgeCrossing: true, + reduceEdgeCrossingMaxEdges: 200, + ); + _currentAlgorithm = CircleLayoutAlgorithm(circleConfig, null); + break; + } + } + + Widget rectangleWidget(int? a) { + return InkWell( + onTap: () { + print('clicked node $a'); + }, + child: Container( + padding: EdgeInsets.all(16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + boxShadow: [ + BoxShadow(color: Colors.blue[100]!, spreadRadius: 1), + ], + ), + child: Text('Node ${a} ')), + ); + } + + final Graph graph = Graph()..isTree = true; + BuchheimWalkerConfiguration builder = BuchheimWalkerConfiguration(); + + void _navigateToRandomNode() { + if (graph.nodes.isEmpty) return; + + final randomNode = graph.nodes.firstWhere( + (node) => node.key != null && node.key!.value == nextNodeId, + orElse: () => graph.nodes.first, + ); + final nodeId = randomNode.key!; + _controller.animateToNode(nodeId); + + setState(() { + nextNodeId = r.nextInt(graph.nodes.length) + 1; + }); + } + + void _resetView() { + _controller.resetView(); + } + + @override + void initState() { + super.initState(); + + var json = { + 'edges': [ + // A0 -> B0, B1, B2 + {'from': 1, 'to': 2}, // A0 -> B0 + {'from': 1, 'to': 3}, // A0 -> B1 + {'from': 1, 'to': 4}, // A0 -> B2 + + // B0 -> C0, C1, C2, C3 + {'from': 2, 'to': 5}, // B0 -> C0 + {'from': 2, 'to': 6}, // B0 -> C1 + {'from': 2, 'to': 7}, // B0 -> C2 + {'from': 2, 'to': 8}, // B0 -> C3 + + // C2 -> H0, H1 + {'from': 7, 'to': 9}, // C2 -> H0 + {'from': 7, 'to': 10}, // C2 -> H1 + + // H1 -> H2, H3 + {'from': 10, 'to': 11}, // H1 -> H2 + {'from': 10, 'to': 12}, // H1 -> H3 + + // H3 -> H4, H5 + {'from': 12, 'to': 13}, // H3 -> H4 + {'from': 12, 'to': 14}, // H3 -> H5 + + // H5 -> H6, H7 + {'from': 14, 'to': 15}, // H5 -> H6 + {'from': 14, 'to': 16}, // H5 -> H7 + + // B1 -> D0, D1, D2 + {'from': 3, 'to': 17}, // B1 -> D0 + {'from': 3, 'to': 18}, // B1 -> D1 + {'from': 3, 'to': 19}, // B1 -> D2 + + // B2 -> E0, E1, E2 + {'from': 4, 'to': 20}, // B2 -> E0 + {'from': 4, 'to': 21}, // B2 -> E1 + {'from': 4, 'to': 22}, // B2 -> E2 + + // D0 -> F0, F1, F2 + {'from': 17, 'to': 23}, // D0 -> F0 + {'from': 17, 'to': 24}, // D0 -> F1 + {'from': 17, 'to': 25}, // D0 -> F2 + + // D1 -> G0, G1, G2, G3, G4, G5, G6, G7 + {'from': 18, 'to': 26}, // D1 -> G0 + {'from': 18, 'to': 27}, // D1 -> G1 + {'from': 18, 'to': 28}, // D1 -> G2 + {'from': 18, 'to': 29}, // D1 -> G3 + {'from': 18, 'to': 30}, // D1 -> G4 + {'from': 18, 'to': 31}, // D1 -> G5 + {'from': 18, 'to': 32}, // D1 -> G6 + {'from': 18, 'to': 33}, // D1 -> G7 + ] + }; + + // Usage code (as in your example) + var edges = json['edges']!; + edges.forEach((element) { + var fromNodeId = element['from']; + var toNodeId = element['to']; + graph.addEdge(Node.Id(fromNodeId), Node.Id(toNodeId)); + }); + + builder + ..siblingSeparation = (100) + ..levelSeparation = (150) + ..subtreeSeparation = (150) + ..orientation = (BuchheimWalkerConfiguration.ORIENTATION_TOP_BOTTOM); + + // Initialize with default algorithm + _updateAlgorithm(); + } +} \ No newline at end of file diff --git a/example/lib/force_directed_graphview.dart b/example/lib/force_directed_graphview.dart index 73bfcb0..b9dcad7 100644 --- a/example/lib/force_directed_graphview.dart +++ b/example/lib/force_directed_graphview.dart @@ -21,9 +21,9 @@ class _GraphClusterViewPageState extends State { boundaryMargin: EdgeInsets.all(8), minScale: 0.001, maxScale: 10000, - child: GraphView( + child: GraphViewCustomPainter( graph: graph, - algorithm: builder, + algorithm: algorithm, paint: Paint() ..color = Colors.green ..strokeWidth = 1 @@ -57,7 +57,7 @@ class _GraphClusterViewPageState extends State { } final Graph graph = Graph(); - late Algorithm builder; + late FruchtermanReingoldAlgorithm algorithm; @override void initState() { @@ -78,7 +78,8 @@ class _GraphClusterViewPageState extends State { graph.addEdge(f, c); graph.addEdge(g, c); graph.addEdge(h, g); - - builder = FruchtermanReingoldAlgorithm(iterations: 1000); + var config = FruchtermanReingoldConfiguration() + ..iterations = 1000; + algorithm = FruchtermanReingoldAlgorithm(config); } } diff --git a/example/lib/graph_cluster_animated.dart b/example/lib/graph_cluster_animated.dart index 7bb73e4..3f5ef00 100644 --- a/example/lib/graph_cluster_animated.dart +++ b/example/lib/graph_cluster_animated.dart @@ -1,10 +1,12 @@ +import 'dart:async'; import 'dart:math'; + import 'package:flutter/material.dart'; import 'package:graphview/GraphView.dart'; class GraphScreen extends StatefulWidget { - final Graph graph; - final Algorithm algorithm; + Graph graph; + FruchtermanReingoldAlgorithm algorithm; final Paint? paint; GraphScreen(this.graph, this.algorithm, this.paint); @@ -14,15 +16,14 @@ class GraphScreen extends StatefulWidget { } class _GraphScreenState extends State { - GraphViewController _controller = GraphViewController(); - final Random r = Random(); - int nextNodeId = 1; + bool animated = true; + Random r = Random(); @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( - title: Text('Graph View'), + title: Text('Graph Screen'), actions: [ IconButton( icon: Icon(Icons.add), @@ -40,81 +41,42 @@ class _GraphScreenState extends State { icon: Icon(Icons.animation), onPressed: () async { setState(() { + animated = !animated; }); }, ) ], ), - - body: Column( - children: [ - // Navigation controls - Wrap( - children: [ - ElevatedButton( - onPressed: () => _navigateToRandomNode(), - child: Text('Go to Node $nextNodeId'), - ), - ElevatedButton( - onPressed: () => _controller.resetView(), - child: Text('Reset View'), - ), - ElevatedButton( - onPressed: () => _controller.zoomToFit(), - child: Text("Zoom to fit"), - ), - ], - ), - Expanded( - child: GraphView.builder( - controller: _controller, - graph: widget.graph, - algorithm: widget.algorithm, - paint: widget.paint, - builder: (Node node) { - var a = node.key?.value; - return rectangleWidget(a); - }, - ), - ), - ], - ), + body: InteractiveViewer( + constrained: false, + boundaryMargin: EdgeInsets.all(100), + minScale: 0.0001, + maxScale: 10.6, + child: GraphViewCustomPainter( + graph: widget.graph, + algorithm: widget.algorithm, + builder: (Node node) { + // I can decide what widget should be shown here based on the id + var a = node.key!.value as String; + return rectangWidget(a); + }, + )), ); } - Widget rectangleWidget(nodeText) { - return InkWell( - onTap: () => print('clicked $nodeText'), - child: Container( - padding: EdgeInsets.all(10), + Widget rectangWidget(String? i) { + return Container( + padding: EdgeInsets.all(16), decoration: BoxDecoration( - color: Colors.red, - border: Border.all(color: Colors.white, width: 1), - borderRadius: BorderRadius.circular(30), - ), - child: Center( - child: Text( - '$nodeText', - style: TextStyle(fontSize: 10), - ), + borderRadius: BorderRadius.circular(4), + boxShadow: [ + BoxShadow(color: Colors.blue, spreadRadius: 1), + ], ), - ), - ); + child: Center(child: Text('Node $i'))); } - void _navigateToRandomNode() { - if (widget.graph.nodes.isEmpty) return; - - final randomNode = widget.graph.nodes.firstWhere( - (node) => node.key != null && node.key!.value == nextNodeId, - orElse: () => widget.graph.nodes.first, - ); - final nodeId = randomNode.key!; - _controller.animateToNode(nodeId); - - setState(() { - nextNodeId = r.nextInt(widget.graph.nodes.length) + 1; - }); + Future update() async { + setState(() {}); } - -} \ No newline at end of file +} diff --git a/example/lib/large_tree_graphview.dart b/example/lib/large_tree_graphview.dart new file mode 100644 index 0000000..8f7eed0 --- /dev/null +++ b/example/lib/large_tree_graphview.dart @@ -0,0 +1,189 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:graphview/GraphView.dart'; + +class LargeTreeViewPage extends StatefulWidget { + @override + _LargeTreeViewPageState createState() => _LargeTreeViewPageState(); +} + +class _LargeTreeViewPageState extends State with TickerProviderStateMixin { + + GraphViewController _controller = GraphViewController(); + final Random r = Random(); + int nextNodeId = 1; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('Tree View'), + ), + body: Column( + mainAxisSize: MainAxisSize.max, + children: [ + // Configuration controls + Wrap( + children: [ + Container( + width: 100, + child: TextFormField( + initialValue: builder.siblingSeparation.toString(), + decoration: InputDecoration(labelText: 'Sibling Separation'), + onChanged: (text) { + builder.siblingSeparation = int.tryParse(text) ?? 100; + this.setState(() {}); + }, + ), + ), + Container( + width: 100, + child: TextFormField( + initialValue: builder.levelSeparation.toString(), + decoration: InputDecoration(labelText: 'Level Separation'), + onChanged: (text) { + builder.levelSeparation = int.tryParse(text) ?? 100; + this.setState(() {}); + }, + ), + ), + Container( + width: 100, + child: TextFormField( + initialValue: builder.subtreeSeparation.toString(), + decoration: InputDecoration(labelText: 'Subtree separation'), + onChanged: (text) { + builder.subtreeSeparation = int.tryParse(text) ?? 100; + this.setState(() {}); + }, + ), + ), + Container( + width: 100, + child: TextFormField( + initialValue: builder.orientation.toString(), + decoration: InputDecoration(labelText: 'Orientation'), + onChanged: (text) { + builder.orientation = int.tryParse(text) ?? 100; + this.setState(() {}); + }, + ), + ), + ElevatedButton( + onPressed: () { + final node12 = Node.Id(r.nextInt(100)); + var edge = graph.getNodeAtPosition(r.nextInt(graph.nodeCount())); + print(edge); + graph.addEdge(edge, node12); + setState(() {}); + }, + child: Text('Add'), + ), + ElevatedButton( + onPressed: _navigateToRandomNode, + child: Text('Go to Node $nextNodeId'), + ), + SizedBox(width: 8), + ElevatedButton( + onPressed: _resetView, + child: Text('Reset View'), + ), + SizedBox(width: 8,), + ElevatedButton(onPressed: (){ + _controller.zoomToFit(); + }, child: Text("Zoom to fit")) + ], + ), + + Expanded( + child: GraphView.builder( + controller: _controller, + graph: graph, + algorithm: algorithm, + // initialNode: ValueKey(1), + panAnimationDuration: Duration(milliseconds: 750), + toggleAnimationDuration: Duration(milliseconds: 750), + // edgeBuilder: (Edge edge, EdgeGeometry geometry) { + // return InteractiveEdge( + // edge: edge, + // geometry: geometry, + // onTap: () => print('Edge tapped: ${edge.key}'), + // color: Colors.red, + // strokeWidth: 3.0, + // ); + // }, + builder: (Node node) => InkWell( + onTap: () => _toggleCollapse(node), + child: Container( + padding: EdgeInsets.all(16), + decoration: BoxDecoration( + shape: BoxShape.circle, + boxShadow: [BoxShadow(color: Colors.blue[100]!, spreadRadius: 1)], + ), + child: Text( + '${node.key?.value}', + ), + ), + ), + ), + ), + ], + )); + } + + final Graph graph = Graph()..isTree = true; + BuchheimWalkerConfiguration builder = BuchheimWalkerConfiguration(); + late final algorithm = BuchheimWalkerAlgorithm(builder, TreeEdgeRenderer(builder)); + + void _toggleCollapse(Node node) { + _controller.toggleNodeExpanded(graph, node, animate: true); + } + + void _navigateToRandomNode() { + if (graph.nodes.isEmpty) return; + + final randomNode = graph.nodes.firstWhere( + (node) => node.key != null && node.key!.value == nextNodeId, + orElse: () => graph.nodes.first, + ); + final nodeId = randomNode.key!; + _controller.animateToNode(nodeId); + + setState(() { + // nextNodeId = r.nextInt(graph.nodes.length) + 1; + }); + } + + void _resetView() { + _controller.animateToNode(ValueKey(1)); + } + + @override + void initState() { + super.initState(); + + var n = 1000; + final nodes = List.generate(n, (i) => Node.Id(i + 1)); + +// Generate tree edges using a queue-based approach + int currentChild = 1; // Start from node 1 (node 0 is root) + + for (var i = 0; i < n && currentChild < n; i++) { + final children = (i < n ~/ 3) ? 3 : 2; + + for (var j = 0; j < children && currentChild < n; j++) { + graph.addEdge(nodes[i], nodes[currentChild]); + currentChild++; + } + } + + builder + ..siblingSeparation = (10) + ..levelSeparation = (100) + ..subtreeSeparation = (10) + ..useCurvedConnections = true + ..orientation = (BuchheimWalkerConfiguration.ORIENTATION_LEFT_RIGHT); + } + +} \ No newline at end of file diff --git a/example/lib/layer_eiglesperger_graphview.dart b/example/lib/layer_eiglesperger_graphview.dart new file mode 100644 index 0000000..7ca9bd9 --- /dev/null +++ b/example/lib/layer_eiglesperger_graphview.dart @@ -0,0 +1,225 @@ +import 'dart:math'; +import 'package:flutter/material.dart'; +import 'package:graphview/GraphView.dart'; + +class LayeredEiglspergerGraphViewPage extends StatefulWidget { + @override + _LayeredEiglspergerGraphViewPageState createState() => _LayeredEiglspergerGraphViewPageState(); +} + +class _LayeredEiglspergerGraphViewPageState extends State { + GraphViewController _controller = GraphViewController(); + final Random r = Random(); + int nextNodeId = 0; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(), + body: Column( + mainAxisSize: MainAxisSize.max, + children: [ + Wrap( + children: [ + Container( + width: 100, + child: TextFormField( + initialValue: builder.nodeSeparation.toString(), + decoration: InputDecoration(labelText: 'Node Separation'), + onChanged: (text) { + builder.nodeSeparation = int.tryParse(text) ?? 100; + this.setState(() {}); + }, + ), + ), + Container( + width: 100, + child: TextFormField( + initialValue: builder.levelSeparation.toString(), + decoration: InputDecoration(labelText: 'Level Separation'), + onChanged: (text) { + builder.levelSeparation = int.tryParse(text) ?? 100; + this.setState(() {}); + }, + ), + ), + Container( + width: 100, + child: TextFormField( + initialValue: builder.orientation.toString(), + decoration: InputDecoration(labelText: 'Orientation'), + onChanged: (text) { + builder.orientation = int.tryParse(text) ?? 100; + this.setState(() {}); + }, + ), + ), + Container( + width: 120, + child: Column( + children: [ + Text('Alignment'), + DropdownButton( + value: builder.coordinateAssignment, + items: CoordinateAssignment.values.map((coordinateAssignment) { + return DropdownMenuItem( + value: coordinateAssignment, + child: Text(coordinateAssignment.name), + ); + }).toList(), + onChanged: (value) { + setState(() { + builder.coordinateAssignment = value!; + }); + }, + ), + ], + ), + ), + ElevatedButton( + onPressed: () { + final node12 = Node.Id(r.nextInt(100)); + var edge = graph.getNodeAtPosition(r.nextInt(graph.nodeCount())); + print(edge); + graph.addEdge(edge, node12); + setState(() {}); + }, + child: Text('Add'), + ), + ElevatedButton( + onPressed: () => _navigateToRandomNode(), + child: Text('Go to Node $nextNodeId'), + ), + ElevatedButton( + onPressed: () => _controller.resetView(), + child: Text('Reset View'), + ), + ElevatedButton( + onPressed: () => _controller.zoomToFit(), + child: Text("Zoom to fit"), + ), + ], + ), + Expanded( + child: GraphView.builder( + controller: _controller, + graph: graph, + algorithm: EiglspergerAlgorithm(builder), + paint: Paint() + ..color = Colors.green + ..strokeWidth = 1 + ..style = PaintingStyle.stroke, + builder: (Node node) { + var a = node.key!.value as int?; + return rectangleWidget(a); + }, + ), + ), + ], + )); + } + + Widget rectangleWidget(int? a) { + return Container( + padding: EdgeInsets.all(16), + decoration: BoxDecoration( + shape: BoxShape.circle, + boxShadow: [ + BoxShadow(color: Colors.blue[100]!, spreadRadius: 1), + ], + ), + child: Text('${a}')); + } + + final Graph graph = Graph(); + SugiyamaConfiguration builder = SugiyamaConfiguration() + ..bendPointShape = CurvedBendPointShape(curveLength: 20); + + void _navigateToRandomNode() { + if (graph.nodes.isEmpty) return; + + final randomNode = graph.nodes.firstWhere( + (node) => node.key != null && node.key!.value == nextNodeId, + orElse: () => graph.nodes.first, + ); + final nodeId = randomNode.key!; + _controller.animateToNode(nodeId); + + setState(() { + nextNodeId = r.nextInt(graph.nodes.length) + 1; + }); + } + + @override + void initState() { + super.initState(); + final node1 = Node.Id(1); + final node2 = Node.Id(2); + final node3 = Node.Id(3); + final node4 = Node.Id(4); + final node5 = Node.Id(5); + final node6 = Node.Id(6); + final node8 = Node.Id(7); + final node7 = Node.Id(8); + final node9 = Node.Id(9); + final node10 = Node.Id(10); + final node11 = Node.Id(11); + final node12 = Node.Id(12); + final node13 = Node.Id(13); + final node14 = Node.Id(14); + final node15 = Node.Id(15); + final node16 = Node.Id(16); + final node17 = Node.Id(17); + final node18 = Node.Id(18); + final node19 = Node.Id(19); + final node20 = Node.Id(20); + final node21 = Node.Id(21); + final node22 = Node.Id(22); + final node23 = Node.Id(23); + + graph.addEdge(node1, node13, paint: Paint()..color = Colors.red); + graph.addEdge(node1, node21); + graph.addEdge(node1, node4); + graph.addEdge(node1, node3); + graph.addEdge(node2, node3); + graph.addEdge(node2, node20); + graph.addEdge(node3, node4); + graph.addEdge(node3, node5); + graph.addEdge(node3, node23); + graph.addEdge(node4, node6); + graph.addEdge(node5, node7); + graph.addEdge(node6, node8); + graph.addEdge(node6, node16); + graph.addEdge(node6, node23); + graph.addEdge(node7, node9); + graph.addEdge(node8, node10); + graph.addEdge(node8, node11); + graph.addEdge(node9, node12); + graph.addEdge(node10, node13); + graph.addEdge(node10, node14); + graph.addEdge(node10, node15); + graph.addEdge(node11, node15); + graph.addEdge(node11, node16); + graph.addEdge(node12, node20); + graph.addEdge(node13, node17); + graph.addEdge(node14, node17); + graph.addEdge(node14, node18); + graph.addEdge(node16, node18); + graph.addEdge(node16, node19); + graph.addEdge(node16, node20); + graph.addEdge(node18, node21); + graph.addEdge(node19, node22); + graph.addEdge(node21, node23); + graph.addEdge(node22, node23); + graph.addEdge(node1, node22); + graph.addEdge(node7, node8); + + builder + ..nodeSeparation = (15) + ..levelSeparation = (15) + ..orientation = SugiyamaConfiguration.ORIENTATION_TOP_BOTTOM; + + // Set initial random node for navigation + nextNodeId = r.nextInt(22); // 0-21 nodes exist + } +} \ No newline at end of file diff --git a/example/lib/layer_graphview.dart b/example/lib/layer_graphview.dart index 658f946..67fa437 100644 --- a/example/lib/layer_graphview.dart +++ b/example/lib/layer_graphview.dart @@ -8,160 +8,257 @@ class LayeredGraphViewPage extends StatefulWidget { } class _LayeredGraphViewPageState extends State { - GraphViewController _controller = GraphViewController(); + final GraphViewController _controller = GraphViewController(); final Random r = Random(); int nextNodeId = 0; + bool _showControls = true; @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(), - body: Column( - mainAxisSize: MainAxisSize.max, + backgroundColor: Colors.grey[50], + appBar: AppBar( + title: Text('Graph Visualizer', style: TextStyle(fontWeight: FontWeight.w600)), + backgroundColor: Colors.white, + foregroundColor: Colors.grey[800], + elevation: 0, + actions: [ + IconButton( + icon: Icon(_showControls ? Icons.visibility_off : Icons.visibility), + onPressed: () => setState(() => _showControls = !_showControls), + tooltip: 'Toggle Controls', + ), + IconButton( + icon: Icon(Icons.shuffle), + onPressed: _navigateToRandomNode, + tooltip: 'Random Node', + ), + ], + ), + body: Column( + children: [ + AnimatedContainer( + duration: Duration(milliseconds: 300), + height: _showControls ? null : 0, + child: AnimatedOpacity( + duration: Duration(milliseconds: 300), + opacity: _showControls ? 1.0 : 0.0, + child: _buildControlPanel(), + ), + ), + Expanded(child: _buildGraphView()), + ], + ), + ); + } + + Widget _buildControlPanel() { + return Container( + margin: EdgeInsets.all(16), + padding: EdgeInsets.all(20), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 10, offset: Offset(0, 2))], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox(height: 16), + _buildNumericControls(), + SizedBox(height: 16), + _buildShapeControls(), + ], + ), + ); + } + + Widget _buildNumericControls() { + return Wrap( + spacing: 12, + runSpacing: 12, + children: [ + _buildSliderControl('Node Sep', builder.nodeSeparation, 5, 50, (v) => builder.nodeSeparation = v), + _buildSliderControl('Level Sep', builder.levelSeparation, 5, 100, (v) => builder.levelSeparation = v), + _buildDropdown('Alignment', builder.coordinateAssignment, CoordinateAssignment.values, (v) => builder.coordinateAssignment = v), + _buildDropdown('Layering', builder.layeringStrategy, LayeringStrategy.values, (v) => builder.layeringStrategy = v), + _buildDropdown('Cross Min', builder.crossMinimizationStrategy, CrossMinimizationStrategy.values, (v) => builder.crossMinimizationStrategy = v), + _buildDropdown('Cycle Removal', builder.cycleRemovalStrategy, CycleRemovalStrategy.values, (v) => builder.cycleRemovalStrategy = v), + ], + ); + } + + Widget _buildSliderControl(String label, int value, int min, int max, Function(int) onChanged) { + return Container( + width: 200, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500)), + Slider( + value: value.toDouble().clamp(min.toDouble(), max.toDouble()), + min: min.toDouble(), + max: max.toDouble(), + divisions: max - min, + label: value.toString(), + onChanged: (v) => setState(() => onChanged(v.round())), + ), + ], + ), + ); + } + + Widget _buildDropdownControls() { + return Wrap( + spacing: 16, + runSpacing: 12, + children: [ + ], + ); + } + + Widget _buildDropdown(String label, T value, List items, Function(T) onChanged) { + return Container( + width: 160, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(label, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500)), + SizedBox(height: 4), + Container( + padding: EdgeInsets.symmetric(horizontal: 12), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey[300]!), + borderRadius: BorderRadius.circular(8), + ), + child: DropdownButtonHideUnderline( + child: DropdownButton( + value: value, + isExpanded: true, + items: items.map((item) => DropdownMenuItem(value: item, child: Text(item.toString().split('.').last, style: TextStyle(fontSize: 12)))).toList(), + onChanged: (v) => setState(() => onChanged(v!)), + ), + ), + ), + ], + ), + ); + } + + Widget _buildShapeControls() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Edge Shape', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500)), + SizedBox(height: 8), + Row( children: [ - Wrap( + _buildShapeButton('Sharp', builder.bendPointShape is SharpBendPointShape, () => builder.bendPointShape = SharpBendPointShape()), + SizedBox(width: 8), + _buildShapeButton('Curved', builder.bendPointShape is CurvedBendPointShape, () => builder.bendPointShape = CurvedBendPointShape(curveLength: 20)), + SizedBox(width: 8), + _buildShapeButton('Max Curved', builder.bendPointShape is MaxCurvedBendPointShape, () => builder.bendPointShape = MaxCurvedBendPointShape()), + Spacer(), + Row( children: [ - Container( - width: 100, - child: TextFormField( - initialValue: builder.nodeSeparation.toString(), - decoration: InputDecoration(labelText: 'Node Separation'), - onChanged: (text) { - builder.nodeSeparation = int.tryParse(text) ?? 100; - this.setState(() {}); - }, - ), - ), - Container( - width: 100, - child: TextFormField( - initialValue: builder.levelSeparation.toString(), - decoration: InputDecoration(labelText: 'Level Separation'), - onChanged: (text) { - builder.levelSeparation = int.tryParse(text) ?? 100; - this.setState(() {}); - }, - ), - ), - Container( - width: 100, - child: TextFormField( - initialValue: builder.orientation.toString(), - decoration: InputDecoration(labelText: 'Orientation'), - onChanged: (text) { - builder.orientation = int.tryParse(text) ?? 100; - this.setState(() {}); - }, - ), - ), - Container( - width: 120, - child: Column( - children: [ - Text('Alignment'), - DropdownButton( - value: builder.coordinateAssignment, - items: CoordinateAssignment.values.map((coordinateAssignment) { - return DropdownMenuItem( - value: coordinateAssignment, - child: Text(coordinateAssignment.name), - ); - }).toList(), - onChanged: (value) { - setState(() { - builder.coordinateAssignment = value!; - }); - }, - ), - ], - ), - ), - ElevatedButton( - onPressed: () { - final node12 = Node.Id(r.nextInt(100)); - var edge = graph.getNodeAtPosition(r.nextInt(graph.nodeCount())); - print(edge); - graph.addEdge(edge, node12); - setState(() {}); - }, - child: Text('Add'), - ), - ElevatedButton( - onPressed: () => _navigateToRandomNode(), - child: Text('Go to Node $nextNodeId'), - ), - ElevatedButton( - onPressed: () => _controller.resetView(), - child: Text('Reset View'), - ), - ElevatedButton( - onPressed: () => _controller.zoomToFit(), - child: Text("Zoom to fit"), + Text('Post Straighten', style: TextStyle(fontSize: 12)), + Switch( + value: builder.postStraighten, + onChanged: (v) => setState(() => builder.postStraighten = v), + activeColor: Colors.blue, ), ], ), - Expanded( - child: GraphView.builder( - controller: _controller, - graph: graph, - algorithm: SugiyamaAlgorithm(builder), - paint: Paint() - ..color = Colors.green - ..strokeWidth = 1 - ..style = PaintingStyle.stroke, - builder: (Node node) { - var a = node.key!.value as int?; - return rectangleWidget(a); - }, - ), - ), ], - )); + ), + ], + ); + } + + Widget _buildShapeButton(String text, bool isSelected, VoidCallback onPressed) { + return ElevatedButton( + onPressed: () => setState(onPressed), + child: Text(text, style: TextStyle(fontSize: 11)), + style: ElevatedButton.styleFrom( + backgroundColor: isSelected ? Colors.blue : Colors.grey[100], + foregroundColor: isSelected ? Colors.white : Colors.grey[700], + elevation: isSelected ? 2 : 0, + padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + ); } - Widget rectangleWidget(int? a) { + Widget _buildGraphView() { return Container( - padding: EdgeInsets.all(16), - decoration: BoxDecoration( - shape: BoxShape.circle, - boxShadow: [ - BoxShadow(color: Colors.blue[100]!, spreadRadius: 1), - ], + margin: EdgeInsets.fromLTRB(16, 0, 16, 16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 10, offset: Offset(0, 2))], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: GraphView.builder( + controller: _controller, + graph: graph, + algorithm: SugiyamaAlgorithm(builder), + paint: Paint() + ..color = Colors.blue[300]! + ..strokeWidth = 2 + ..style = PaintingStyle.stroke, + builder: (Node node) { + final nodeId = node.key!.value as int; + return Container( + width: 40, + height: 40, + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [Colors.blue[400]!, Colors.blue[600]!], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + shape: BoxShape.circle, + boxShadow: [BoxShadow(color: Colors.blue[100]!, blurRadius: 8, offset: Offset(0, 2))], + ), + child: Center( + child: Text('$nodeId', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w600, fontSize: 14)), + ), + ); + }, ), - child: Text('${a}')); + ), + ); } final Graph graph = Graph(); SugiyamaConfiguration builder = SugiyamaConfiguration() - ..bendPointShape = CurvedBendPointShape(curveLength: 20); + ..bendPointShape = CurvedBendPointShape(curveLength: 20) + ..nodeSeparation = 15 + ..levelSeparation = 15 + ..orientation = SugiyamaConfiguration.ORIENTATION_TOP_BOTTOM; void _navigateToRandomNode() { if (graph.nodes.isEmpty) return; - - final randomNode = graph.nodes.firstWhere( - (node) => node.key != null && node.key!.value == nextNodeId, - orElse: () => graph.nodes.first, - ); - final nodeId = randomNode.key!; - _controller.animateToNode(nodeId); - - setState(() { - nextNodeId = r.nextInt(graph.nodes.length) + 1; - }); + final randomNode = graph.nodes[r.nextInt(graph.nodes.length)]; + _controller.animateToNode(randomNode.key!); } @override void initState() { super.initState(); - final node0 = Node.Id(0); + _initializeGraph(); + } + + void _initializeGraph() { + // Define edges more concisely final node1 = Node.Id(1); final node2 = Node.Id(2); final node3 = Node.Id(3); final node4 = Node.Id(4); final node5 = Node.Id(5); final node6 = Node.Id(6); - final node7 = Node.Id(7); - final node8 = Node.Id(8); + final node8 = Node.Id(7); + final node7 = Node.Id(8); final node9 = Node.Id(9); final node10 = Node.Id(10); final node11 = Node.Id(11); @@ -175,38 +272,44 @@ class _LayeredGraphViewPageState extends State { final node19 = Node.Id(19); final node20 = Node.Id(20); final node21 = Node.Id(21); + final node22 = Node.Id(22); + final node23 = Node.Id(23); - // Adding edges based on parent-child relationships - graph.addEdge(node8, node0); - graph.addEdge(node2, node11); - graph.addEdge(node11, node3); - graph.addEdge(node12, node4); - graph.addEdge(node4, node9); - graph.addEdge(node18, node5); - graph.addEdge(node9, node6); - graph.addEdge(node15, node6); - graph.addEdge(node17, node6); - graph.addEdge(node3, node7); - graph.addEdge(node17, node7); - graph.addEdge(node20, node7); - graph.addEdge(node21, node7); - graph.addEdge(node0, node16); - graph.addEdge(node21, node10); - graph.addEdge(node16, node10); - graph.addEdge(node21, node12); - graph.addEdge(node4, node13); - graph.addEdge(node12, node13); - graph.addEdge(node1, node14); - graph.addEdge(node8, node14); - graph.addEdge(node9, node18); - graph.addEdge(node19, node17); - - builder - ..nodeSeparation = (15) - ..levelSeparation = (15) - ..orientation = SugiyamaConfiguration.ORIENTATION_TOP_BOTTOM; - - // Set initial random node for navigation - nextNodeId = r.nextInt(22); // 0-21 nodes exist + graph.addEdge(node1, node13, paint: Paint()..color = Colors.red); + graph.addEdge(node1, node21); + graph.addEdge(node1, node4); + graph.addEdge(node1, node3); + graph.addEdge(node2, node3); + graph.addEdge(node2, node20); + graph.addEdge(node3, node4); + graph.addEdge(node3, node5); + graph.addEdge(node3, node23); + graph.addEdge(node4, node6); + graph.addEdge(node5, node7); + graph.addEdge(node6, node8); + graph.addEdge(node6, node16); + graph.addEdge(node6, node23); + graph.addEdge(node7, node9); + graph.addEdge(node8, node10); + graph.addEdge(node8, node11); + graph.addEdge(node9, node12); + graph.addEdge(node10, node13); + graph.addEdge(node10, node14); + graph.addEdge(node10, node15); + graph.addEdge(node11, node15); + graph.addEdge(node11, node16); + graph.addEdge(node12, node20); + graph.addEdge(node13, node17); + graph.addEdge(node14, node17); + graph.addEdge(node14, node18); + graph.addEdge(node16, node18); + graph.addEdge(node16, node19); + graph.addEdge(node16, node20); + graph.addEdge(node18, node21); + graph.addEdge(node19, node22); + graph.addEdge(node21, node23); + graph.addEdge(node22, node23); + graph.addEdge(node1, node22); + graph.addEdge(node7, node8); } } \ No newline at end of file diff --git a/example/lib/layer_graphview_json.dart b/example/lib/layer_graphview_json.dart index 82958e2..1ee1fb1 100644 --- a/example/lib/layer_graphview_json.dart +++ b/example/lib/layer_graphview_json.dart @@ -12,81 +12,82 @@ class _LayerGraphPageFromJsonState extends State { var json = { "edges": [ { - "from": "254022114", - "to": "435737192" + "from": "1", + "to": "2" }, { - "from": "102061118", - "to": "435737192" + "from": "3", + "to": "2" }, { - "from": "864374573", - "to": "676874082" + "from": "4", + "to": "5" }, { - "from": "564905731", - "to": "864374573" + "from": "6", + "to": "4" }, { - "from": "435737192", - "to": "864374573" + "from": "2", + "to": "4" }, { - "from": "435737192", - "to": "183014792" + "from": "2", + "to": "7" }, { - "from": "435737192", - "to": "222855694" + "from": "2", + "to": "8" }, { - "from": "864342115", - "to": "652678503" + "from": "9", + "to": "10" }, { - "from": "864342115", - "to": "469600377" + "from": "9", + "to": "11" }, { - "from": "676874082", - "to": "684761235" + "from": "5", + "to": "12" }, { - "from": "864374573", - "to": "864342115" + "from": "4", + "to": "9" }, { - "from": "564905731", - "to": "176177853" + "from": "6", + "to": "13" }, { - "from": "564905731", - "to": "983393593" + "from": "6", + "to": "14" }, { - "from": "564905731", - "to": "818531897" + "from": "6", + "to": "15" }, { - "from": "584192116", - "to": "102061118" + "from": "16", + "to": "3" }, { - "from": "598554018", - "to": "102061118" + "from": "17", + "to": "3" }, { - "from": "207392962", - "to": "584192116" + "from": "18", + "to": "16" }, { - "from": "161904647", - "to": "598554018" + "from": "19", + "to": "17" }, { - "from": "469600377", - "to": "254022114" - } + "from": "11", + "to": "1" + }, + ] }; @@ -228,8 +229,7 @@ class _LayerGraphPageFromJsonState extends State { ); } - final Graph graph = Graph() - ..isTree = true; + final Graph graph = Graph(); @override void initState() { var edges = json['edges']!; diff --git a/example/lib/main.dart b/example/lib/main.dart index 28ca5ac..1c046b2 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,11 +1,16 @@ +import 'package:example/algorithm_selector_graphview.dart'; import 'package:example/decision_tree_screen.dart'; +import 'package:example/large_tree_graphview.dart'; import 'package:example/layer_graphview.dart'; +import 'package:example/mindmap_graphview.dart'; +import 'package:example/mutliple_forest_graphview.dart'; import 'package:example/tree_graphview_json.dart'; import 'package:flutter/material.dart'; import 'package:graphview/GraphView.dart'; import 'force_directed_graphview.dart'; import 'graph_cluster_animated.dart'; +import 'layer_eiglesperger_graphview.dart'; import 'layer_graphview_json.dart'; import 'tree_graphview.dart'; @@ -71,48 +76,60 @@ class Home extends StatelessWidget { 'BuchheimWalker Algorithm', Icons.account_tree, Colors.deepPurple, - () => TreeViewPage(), + () => TreeViewPage(), ), _buildButton( 'Tree from JSON', 'Dynamic tree generation', Icons.data_object, Colors.indigo, - () => TreeViewPageFromJson(), + () => TreeViewPageFromJson(), + ), + _buildButton( + 'Large Tree View', + '1000 nodes', + Icons.data_object, + Colors.indigo, + () => LargeTreeViewPage(), + ), + _buildButton( + 'Multiple Forest Tree View', + 'Multiple Nodes', + Icons.data_object, + Colors.indigo, + () => MultipleForestTreeViewPage(), ), ]), - _buildSection('Layered Algorithms', [ _buildButton( 'Layered View', 'Sugiyama Algorithm', Icons.layers, Colors.teal, - () => LayeredGraphViewPage(), + () => LayeredGraphViewPage(), ), _buildButton( 'Layer from JSON', 'JSON-based layered graphs', Icons.timeline, Colors.cyan, - () => LayerGraphPageFromJson(), + () => LayerGraphPageFromJson(), ), _buildButton( 'Decision Tree', 'Decision-making visualization', Icons.device_hub, Colors.green, - () => DecisionTreeScreen(), + () => DecisionTreeScreen(), ), ]), - _buildSection('Cluster Algorithms', [ _buildButton( 'Graph Cluster', 'FruchtermanReingold Algorithm', Icons.bubble_chart, Colors.orange, - () => GraphClusterViewPage(), + () => GraphClusterViewPage(), ), _buildCustomGraphButton( 'Square Grid', @@ -129,8 +146,29 @@ class Home extends StatelessWidget { _createTriangleGraph, ), ]), - - + _buildSection('Specialized Views', [ + _buildButton( + 'Algorithm SelectorPage', + 'Multiple Algorithms using the same graph', + Icons.code, + Colors.brown, + () => AlgorithmSelectedVIewPage(), + ), + _buildButton( + 'Mind Map', + 'Conceptual mapping', + Icons.psychology, + Colors.purple, + () => MindMapPage(), + ), + _buildButton( + 'Layered View', + 'Eiglesperger Algorithm (Broken)', + Icons.layers, + Colors.teal, + () => LayeredEiglspergerGraphViewPage(), + ), + ]), ], ), ); @@ -155,21 +193,21 @@ class Home extends StatelessWidget { ), ), ...buttons.map((button) => Padding( - padding: EdgeInsets.only(bottom: 12), - child: button, - )), + padding: EdgeInsets.only(bottom: 12), + child: button, + )), ], ), ); } Widget _buildButton( - String title, - String subtitle, - IconData icon, - Color color, - Widget Function() pageBuilder, - ) { + String title, + String subtitle, + IconData icon, + Color color, + Widget Function() pageBuilder, + ) { return Builder( builder: (context) => Container( height: 80, @@ -256,12 +294,12 @@ class Home extends StatelessWidget { } Widget _buildCustomGraphButton( - String title, - String subtitle, - IconData icon, - Color color, - Graph Function() graphBuilder, - ) { + String title, + String subtitle, + IconData icon, + Color color, + Graph Function() graphBuilder, + ) { return Builder( builder: (context) => Container( height: 80, @@ -273,7 +311,9 @@ class Home extends StatelessWidget { borderRadius: BorderRadius.circular(16), onTap: () { var graph = graphBuilder(); - var builder = FruchtermanReingoldAlgorithm(); + + var builder = FruchtermanReingoldAlgorithm( + FruchtermanReingoldConfiguration()); Navigator.push( context, MaterialPageRoute( @@ -415,4 +455,4 @@ class Home extends StatelessWidget { return graph; } -} \ No newline at end of file +} diff --git a/example/lib/mindmap_graphview.dart b/example/lib/mindmap_graphview.dart new file mode 100644 index 0000000..602afde --- /dev/null +++ b/example/lib/mindmap_graphview.dart @@ -0,0 +1,295 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:graphview/GraphView.dart'; + +class MindMapPage extends StatefulWidget { + @override + _MindMapPageState createState() => _MindMapPageState(); +} + +class _MindMapPageState extends State with TickerProviderStateMixin { + + GraphViewController _controller = GraphViewController(); + final Random r = Random(); + int nextNodeId = 1; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('Tree View'), + ), + body: Column( + mainAxisSize: MainAxisSize.max, + children: [ + // Configuration controls + Wrap( + children: [ + Container( + width: 100, + child: TextFormField( + initialValue: builder.siblingSeparation.toString(), + decoration: InputDecoration(labelText: 'Sibling Separation'), + onChanged: (text) { + builder.siblingSeparation = int.tryParse(text) ?? 100; + this.setState(() {}); + }, + ), + ), + Container( + width: 100, + child: TextFormField( + initialValue: builder.levelSeparation.toString(), + decoration: InputDecoration(labelText: 'Level Separation'), + onChanged: (text) { + builder.levelSeparation = int.tryParse(text) ?? 100; + this.setState(() {}); + }, + ), + ), + Container( + width: 100, + child: TextFormField( + initialValue: builder.subtreeSeparation.toString(), + decoration: InputDecoration(labelText: 'Subtree separation'), + onChanged: (text) { + builder.subtreeSeparation = int.tryParse(text) ?? 100; + this.setState(() {}); + }, + ), + ), + Container( + width: 100, + child: TextFormField( + initialValue: builder.orientation.toString(), + decoration: InputDecoration(labelText: 'Orientation'), + onChanged: (text) { + builder.orientation = int.tryParse(text) ?? 100; + this.setState(() {}); + }, + ), + ), + ElevatedButton( + onPressed: () { + final node12 = Node.Id(r.nextInt(100)); + var edge = graph.getNodeAtPosition(r.nextInt(graph.nodeCount())); + print(edge); + graph.addEdge(edge, node12); + setState(() {}); + }, + child: Text('Add'), + ), + ElevatedButton( + onPressed: _navigateToRandomNode, + child: Text('Go to Node $nextNodeId'), + ), + SizedBox(width: 8), + ElevatedButton( + onPressed: _resetView, + child: Text('Reset View'), + ), + SizedBox(width: 8,), + ElevatedButton(onPressed: (){ + _controller.zoomToFit(); + }, child: Text("Zoom to fit")) + ], + ), + + Expanded( + child: GraphView.builder( + controller: _controller, + graph: graph, + algorithm: MindmapAlgorithm( + builder, MindmapEdgeRenderer(builder) + ), + builder: (Node node) => Container( + padding: EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(4), + boxShadow: [BoxShadow(color: Colors.blue[100]!, spreadRadius: 1)], + ), + child: Text( + 'Node ${node.key?.value}', + ), + ), + ), + ), + ], + )); + } + + Widget rectangleWidget(int? a) { + return InkWell( + onTap: () { + print('clicked node $a'); + }, + child: Container( + padding: EdgeInsets.all(16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + boxShadow: [ + BoxShadow(color: Colors.blue[100]!, spreadRadius: 1), + ], + ), + child: Text('Node ${a} ')), + ); + } + + final Graph graph = Graph()..isTree = true; + BuchheimWalkerConfiguration builder = BuchheimWalkerConfiguration(); + + void _navigateToRandomNode() { + if (graph.nodes.isEmpty) return; + + final randomNode = graph.nodes.firstWhere( + (node) => node.key != null && node.key!.value == nextNodeId, + orElse: () => graph.nodes.first, + ); + final nodeId = randomNode.key!; + _controller.animateToNode(nodeId); + + setState(() { + nextNodeId = r.nextInt(graph.nodes.length) + 1; + }); + } + + void _resetView() { + _controller.resetView(); + } + + @override + void initState() { + super.initState(); + + +// Complex Mindmap Test - This will stress test the balancing algorithm + +// Create all nodes + final root = Node.Id(1); // Central topic + +// Left side - Technology branch (will be large) + final tech = Node.Id(2); + final ai = Node.Id(3); + final web = Node.Id(4); + final mobile = Node.Id(5); + final aiSubtopics = [ + Node.Id(6), // Machine Learning + Node.Id(7), // Deep Learning + Node.Id(8), // NLP + Node.Id(9), // Computer Vision + ]; + final webSubtopics = [ + Node.Id(10), // Frontend + Node.Id(11), // Backend + Node.Id(12), // DevOps + ]; + final frontendDetails = [ + Node.Id(13), // React + Node.Id(14), // Vue + Node.Id(15), // Angular + ]; + final backendDetails = [ + Node.Id(16), // Node.js + Node.Id(17), // Python + Node.Id(18), // Java + Node.Id(19), // Go + ]; + +// Right side - Business branch (will be smaller to test balancing) + final business = Node.Id(20); + final marketing = Node.Id(21); + final sales = Node.Id(22); + final finance = Node.Id(23); + final marketingDetails = [ + Node.Id(24), // Digital Marketing + Node.Id(25), // Content Strategy + ]; + final salesDetails = [ + Node.Id(26), // B2B Sales + Node.Id(27), // Customer Success + ]; + +// Additional right side - Personal branch + final personal = Node.Id(28); + final health = Node.Id(29); + final hobbies = Node.Id(30); + final healthDetails = [ + Node.Id(31), // Exercise + Node.Id(32), // Nutrition + Node.Id(33), // Mental Health + ]; + final exerciseDetails = [ + Node.Id(34), // Cardio + Node.Id(35), // Strength Training + Node.Id(36), // Yoga + ]; + +// Build the graph structure + graph.addEdge(root, tech); + graph.addEdge(root, business, paint: Paint()..color = Colors.blue); + graph.addEdge(root, personal, paint: Paint()..color = Colors.green); + +// Technology branch (left side - large subtree) + graph.addEdge(tech, ai); + graph.addEdge(tech, web); + graph.addEdge(tech, mobile); + +// AI subtree + for (final aiNode in aiSubtopics) { + graph.addEdge(ai, aiNode, paint: Paint()..color = Colors.purple); + } + +// Web subtree with deep nesting + for (final webNode in webSubtopics) { + graph.addEdge(web, webNode, paint: Paint()..color = Colors.orange); + } + +// Frontend details (3rd level) + for (final frontendNode in frontendDetails) { + graph.addEdge(webSubtopics[0], frontendNode, paint: Paint()..color = Colors.cyan); + } + +// Backend details (3rd level) - even deeper + for (final backendNode in backendDetails) { + graph.addEdge(webSubtopics[1], backendNode, paint: Paint()..color = Colors.teal); + } + +// Business branch (right side - smaller subtree) + graph.addEdge(business, marketing); + graph.addEdge(business, sales); + graph.addEdge(business, finance); + +// Marketing details + for (final marketingNode in marketingDetails) { + graph.addEdge(marketing, marketingNode, paint: Paint()..color = Colors.red); + } + +// Sales details + for (final salesNode in salesDetails) { + graph.addEdge(sales, salesNode, paint: Paint()..color = Colors.indigo); + } + +// Personal branch (right side - medium subtree) + graph.addEdge(personal, health); + graph.addEdge(personal, hobbies); + +// Health details + for (final healthNode in healthDetails) { + graph.addEdge(health, healthNode, paint: Paint()..color = Colors.lightGreen); + } + +// Exercise details (3rd level) + for (final exerciseNode in exerciseDetails) { + graph.addEdge(healthDetails[0], exerciseNode, paint: Paint()..color = Colors.amber); + } + + builder + ..siblingSeparation = (100) + ..levelSeparation = (150) + ..subtreeSeparation = (150) + ..orientation = (BuchheimWalkerConfiguration.ORIENTATION_TOP_BOTTOM); + } + +} \ No newline at end of file diff --git a/example/lib/mutliple_forest_graphview.dart b/example/lib/mutliple_forest_graphview.dart new file mode 100644 index 0000000..ed59a73 --- /dev/null +++ b/example/lib/mutliple_forest_graphview.dart @@ -0,0 +1,190 @@ +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:graphview/GraphView.dart'; + +class MultipleForestTreeViewPage extends StatefulWidget { + @override + _TreeViewPageState createState() => _TreeViewPageState(); +} + +class _TreeViewPageState extends State with TickerProviderStateMixin { + + GraphViewController _controller = GraphViewController(); + final Random r = Random(); + int nextNodeId = 1; + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('Tree View'), + ), + body: Column( + mainAxisSize: MainAxisSize.max, + children: [ + // Configuration controls + Wrap( + children: [ + Container( + width: 100, + child: TextFormField( + initialValue: builder.siblingSeparation.toString(), + decoration: InputDecoration(labelText: 'Sibling Separation'), + onChanged: (text) { + builder.siblingSeparation = int.tryParse(text) ?? 100; + this.setState(() {}); + }, + ), + ), + Container( + width: 100, + child: TextFormField( + initialValue: builder.levelSeparation.toString(), + decoration: InputDecoration(labelText: 'Level Separation'), + onChanged: (text) { + builder.levelSeparation = int.tryParse(text) ?? 100; + this.setState(() {}); + }, + ), + ), + Container( + width: 100, + child: TextFormField( + initialValue: builder.subtreeSeparation.toString(), + decoration: InputDecoration(labelText: 'Subtree separation'), + onChanged: (text) { + builder.subtreeSeparation = int.tryParse(text) ?? 100; + this.setState(() {}); + }, + ), + ), + Container( + width: 100, + child: TextFormField( + initialValue: builder.orientation.toString(), + decoration: InputDecoration(labelText: 'Orientation'), + onChanged: (text) { + builder.orientation = int.tryParse(text) ?? 100; + this.setState(() {}); + }, + ), + ), + ElevatedButton( + onPressed: () { + final node12 = Node.Id(r.nextInt(100)); + var edge = graph.getNodeAtPosition(r.nextInt(graph.nodeCount())); + print(edge); + graph.addEdge(edge, node12); + setState(() {}); + }, + child: Text('Add'), + ), + ElevatedButton( + onPressed: _navigateToRandomNode, + child: Text('Go to Node $nextNodeId'), + ), + SizedBox(width: 8), + ElevatedButton( + onPressed: _resetView, + child: Text('Reset View'), + ), + SizedBox(width: 8,), + ElevatedButton(onPressed: (){ + _controller.zoomToFit(); + }, child: Text("Zoom to fit")) + ], + ), + + Expanded( + child: GraphView.builder( + controller: _controller, + graph: graph, + algorithm: TidierTreeLayoutAlgorithm(builder, null), + builder: (Node node) => Container( + padding: EdgeInsets.all(8), + decoration: BoxDecoration( + color: Colors.lightBlue[100], + borderRadius: BorderRadius.circular(8), + ), + child: Text(node.key?.value.toString() ?? ''), + ), + ) + ), + ], + )); + } + + Widget rectangleWidget(int? a) { + return InkWell( + onTap: () { + print('clicked node $a'); + }, + child: Container( + padding: EdgeInsets.all(16), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + boxShadow: [ + BoxShadow(color: Colors.blue[100]!, spreadRadius: 1), + ], + ), + child: Text('Node ${a} ')), + ); + } + + final Graph graph = Graph()..isTree = true; + BuchheimWalkerConfiguration builder = BuchheimWalkerConfiguration(); + + void _navigateToRandomNode() { + if (graph.nodes.isEmpty) return; + + final randomNode = graph.nodes.firstWhere( + (node) => node.key != null && node.key!.value == nextNodeId, + orElse: () => graph.nodes.first, + ); + final nodeId = randomNode.key!; + _controller.animateToNode(nodeId); + + setState(() { + nextNodeId = r.nextInt(graph.nodes.length) + 1; + }); + } + + void _resetView() { + _controller.resetView(); + } + + @override + void initState() { + super.initState(); + + var json = { + 'edges': [ + {'from': 1, 'to': 2}, + {'from': 9, 'to': 2}, + {'from': 10, 'to': 2}, + {'from': 2, 'to': 3}, + {'from': 2, 'to': 4}, + {'from': 2, 'to': 5}, + {'from': 5, 'to': 6}, + {'from': 5, 'to': 7}, + {'from': 6, 'to': 8}, + {'from': 12, 'to': 11}, + ] + }; + + var edges = json['edges']!; + edges.forEach((element) { + var fromNodeId = element['from']; + var toNodeId = element['to']; + graph.addEdge(Node.Id(fromNodeId), Node.Id(toNodeId)); + }); + + builder + ..siblingSeparation = (100) + ..levelSeparation = (150) + ..subtreeSeparation = (150) + ..orientation = (BuchheimWalkerConfiguration.ORIENTATION_TOP_BOTTOM); + } + +} \ No newline at end of file diff --git a/example/lib/tree_graphview.dart b/example/lib/tree_graphview.dart index 4684a1e..d4d5165 100644 --- a/example/lib/tree_graphview.dart +++ b/example/lib/tree_graphview.dart @@ -100,9 +100,13 @@ class _TreeViewPageState extends State with TickerProviderStateMix child: GraphView.builder( controller: _controller, graph: graph, - algorithm: BuchheimWalkerAlgorithm(builder, TreeEdgeRenderer(builder)), - builder: (Node node) => InkWell( - onTap: () => _controller.animateToNode(node.key?.value), + algorithm: algorithm, + initialNode: ValueKey(1), + panAnimationDuration: Duration(milliseconds: 600), + toggleAnimationDuration: Duration(milliseconds: 600), + centerGraph: true, + builder: (Node node) => GestureDetector( + onTap: () => _toggleCollapse(node), child: Container( padding: EdgeInsets.all(16), decoration: BoxDecoration( @@ -110,7 +114,9 @@ class _TreeViewPageState extends State with TickerProviderStateMix borderRadius: BorderRadius.circular(4), boxShadow: [BoxShadow(color: Colors.blue[100]!, spreadRadius: 1)], ), - child: Text('Node ${node.key?.value} \n${graph.nodes.firstWhere((n) => n.key == node.key).position}'), + child: Text( + 'Node ${node.key?.value}', + ), ), ), ), @@ -119,25 +125,13 @@ class _TreeViewPageState extends State with TickerProviderStateMix )); } - Widget rectangleWidget(int? a) { - return InkWell( - onTap: () { - print('clicked node $a'); - }, - child: Container( - padding: EdgeInsets.all(16), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(4), - boxShadow: [ - BoxShadow(color: Colors.blue[100]!, spreadRadius: 1), - ], - ), - child: Text('Node ${a} ')), - ); - } - final Graph graph = Graph()..isTree = true; BuchheimWalkerConfiguration builder = BuchheimWalkerConfiguration(); + late final algorithm = BuchheimWalkerAlgorithm(builder, TreeEdgeRenderer(builder)); + + void _toggleCollapse(Node node) { + _controller.toggleNodeExpanded(graph, node, animate: true); + } void _navigateToRandomNode() { if (graph.nodes.isEmpty) return; @@ -150,46 +144,145 @@ class _TreeViewPageState extends State with TickerProviderStateMix _controller.animateToNode(nodeId); setState(() { - nextNodeId = r.nextInt(graph.nodes.length) + 1; + // nextNodeId = r.nextInt(graph.nodes.length) + 1; }); } void _resetView() { - _controller.resetView(); + _controller.animateToNode(ValueKey(1)); } @override void initState() { super.initState(); - final node1 = Node.Id(1); - final node2 = Node.Id(2); - final node3 = Node.Id(3); - final node4 = Node.Id(4); - final node5 = Node.Id(5); - final node6 = Node.Id(6); - final node8 = Node.Id(7); - final node7 = Node.Id(8); - final node9 = Node.Id(9); - final node10 = Node.Id(10); - final node11 = Node.Id(11); - final node12 = Node.Id(12); - graph.addEdge(node1, node2); - graph.addEdge(node1, node3, paint: Paint()..color = Colors.red); - graph.addEdge(node1, node4, paint: Paint()..color = Colors.blue); - graph.addEdge(node2, node5); - graph.addEdge(node2, node6); - graph.addEdge(node6, node7, paint: Paint()..color = Colors.red); - graph.addEdge(node6, node8, paint: Paint()..color = Colors.red); - graph.addEdge(node4, node9); - graph.addEdge(node4, node10, paint: Paint()..color = Colors.black); - graph.addEdge(node4, node11, paint: Paint()..color = Colors.red); - graph.addEdge(node11, node12); + + +// Create all nodes + final root = Node.Id(1); // Central topic + +// Left side - Technology branch (will be large) + final tech = Node.Id(2); + final ai = Node.Id(3); + final web = Node.Id(4); + final mobile = Node.Id(5); + final aiSubtopics = [ + Node.Id(6), // Machine Learning + Node.Id(7), // Deep Learning + Node.Id(8), // NLP + Node.Id(9), // Computer Vision + ]; + final webSubtopics = [ + Node.Id(10), // Frontend + Node.Id(11), // Backend + Node.Id(12), // DevOps + ]; + final frontendDetails = [ + Node.Id(13), // React + Node.Id(14), // Vue + Node.Id(15), // Angular + ]; + final backendDetails = [ + Node.Id(16), // Node.js + Node.Id(17), // Python + Node.Id(18), // Java + Node.Id(19), // Go + ]; + +// Right side - Business branch (will be smaller to test balancing) + final business = Node.Id(20); + final marketing = Node.Id(21); + final sales = Node.Id(22); + final finance = Node.Id(23); + final marketingDetails = [ + Node.Id(24), // Digital Marketing + Node.Id(25), // Content Strategy + ]; + final salesDetails = [ + Node.Id(26), // B2B Sales + Node.Id(27), // Customer Success + ]; + +// Additional right side - Personal branch + final personal = Node.Id(28); + final health = Node.Id(29); + final hobbies = Node.Id(30); + final healthDetails = [ + Node.Id(31), // Exercise + Node.Id(32), // Nutrition + Node.Id(33), // Mental Health + ]; + final exerciseDetails = [ + Node.Id(34), // Cardio + Node.Id(35), // Strength Training + Node.Id(36), // Yoga + ]; + + _controller.setInitiallyCollapsedNodes([tech, business, personal]); + // Build the graph structure + graph.addEdge(root, tech); + graph.addEdge(root, business, paint: Paint()..color = Colors.blue); + graph.addEdge(root, personal, paint: Paint()..color = Colors.green); + +// // Technology branch (left side - large subtree) + graph.addEdge(tech, ai); + graph.addEdge(tech, web); + graph.addEdge(tech, mobile); + +// AI subtree + for (final aiNode in aiSubtopics) { + graph.addEdge(ai, aiNode, paint: Paint()..color = Colors.purple); + } + +// Web subtree with deep nesting + for (final webNode in webSubtopics) { + graph.addEdge(web, webNode, paint: Paint()..color = Colors.orange); + } + +// Frontend details (3rd level) + for (final frontendNode in frontendDetails) { + graph.addEdge(webSubtopics[0], frontendNode, paint: Paint()..color = Colors.cyan); + } + +// Backend details (3rd level) - even deeper + for (final backendNode in backendDetails) { + graph.addEdge(webSubtopics[1], backendNode, paint: Paint()..color = Colors.teal); + } + +// Business branch (right side - smaller subtree) + graph.addEdge(business, marketing); + graph.addEdge(business, sales); + graph.addEdge(business, finance); + +// Marketing details + for (final marketingNode in marketingDetails) { + graph.addEdge(marketing, marketingNode, paint: Paint()..color = Colors.red); + } + +// Sales details + for (final salesNode in salesDetails) { + graph.addEdge(sales, salesNode, paint: Paint()..color = Colors.indigo); + } + +// Personal branch (right side - medium subtree) + graph.addEdge(personal, health); + graph.addEdge(personal, hobbies); + +// Health details + for (final healthNode in healthDetails) { + graph.addEdge(health, healthNode, paint: Paint()..color = Colors.lightGreen); + } + +// Exercise details (3rd level) + for (final exerciseNode in exerciseDetails) { + graph.addEdge(healthDetails[0], exerciseNode, paint: Paint()..color = Colors.amber); + } builder ..siblingSeparation = (100) ..levelSeparation = (150) ..subtreeSeparation = (150) + ..useCurvedConnections = true ..orientation = (BuchheimWalkerConfiguration.ORIENTATION_TOP_BOTTOM); } diff --git a/example/pubspec.lock b/example/pubspec.lock index ed0ff0e..1c16dd0 100644 --- a/example/pubspec.lock +++ b/example/pubspec.lock @@ -5,51 +5,58 @@ packages: dependency: transitive description: name: async - url: "https://pub.dartlang.org" + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" source: hosted - version: "2.8.1" + version: "2.13.0" boolean_selector: dependency: transitive description: name: boolean_selector - url: "https://pub.dartlang.org" + sha256: "5bbf32bc9e518d41ec49718e2931cd4527292c9b0c6d2dffcf7fe6b9a8a8cf72" + url: "https://pub.dev" source: hosted version: "2.1.0" characters: dependency: transitive description: name: characters - url: "https://pub.dartlang.org" + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.4.0" charcode: dependency: transitive description: name: charcode - url: "https://pub.dartlang.org" + sha256: fb98c0f6d12c920a02ee2d998da788bca066ca5f148492b7085ee23372b12306 + url: "https://pub.dev" source: hosted version: "1.3.1" clock: dependency: transitive description: name: clock - url: "https://pub.dartlang.org" + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.2" collection: dependency: transitive description: name: collection - url: "https://pub.dartlang.org" + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" source: hosted - version: "1.15.0" + version: "1.19.1" fake_async: dependency: transitive description: name: fake_async - url: "https://pub.dartlang.org" + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.3" flutter: dependency: "direct main" description: flutter @@ -66,103 +73,148 @@ packages: path: ".." relative: true source: path - version: "1.1.1" + version: "1.5.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" matcher: dependency: transitive description: name: matcher - url: "https://pub.dartlang.org" + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" source: hosted - version: "0.12.10" + version: "0.11.1" meta: dependency: transitive description: name: meta - url: "https://pub.dartlang.org" + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + url: "https://pub.dev" source: hosted - version: "1.7.0" + version: "1.16.0" nested: dependency: transitive description: name: nested - url: "https://pub.dartlang.org" + sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" + url: "https://pub.dev" source: hosted version: "1.0.0" path: dependency: transitive description: name: path - url: "https://pub.dartlang.org" + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" source: hosted - version: "1.8.0" + version: "1.9.1" provider: dependency: "direct main" description: name: provider - url: "https://pub.dartlang.org" + sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" + url: "https://pub.dev" source: hosted - version: "4.3.3" + version: "6.1.5+1" sky_engine: dependency: transitive description: flutter source: sdk - version: "0.0.99" + version: "0.0.0" source_span: dependency: transitive description: name: source_span - url: "https://pub.dartlang.org" + sha256: d5f89a9e52b36240a80282b3dc0667dd36e53459717bb17b8fb102d30496606a + url: "https://pub.dev" source: hosted version: "1.8.1" stack_trace: dependency: transitive description: name: stack_trace - url: "https://pub.dartlang.org" + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" source: hosted - version: "1.10.0" + version: "1.12.1" stream_channel: dependency: transitive description: name: stream_channel - url: "https://pub.dartlang.org" + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.4" string_scanner: dependency: transitive description: name: string_scanner - url: "https://pub.dartlang.org" + sha256: dd11571b8a03f7cadcf91ec26a77e02bfbd6bbba2a512924d3116646b4198fc4 + url: "https://pub.dev" source: hosted version: "1.1.0" term_glyph: dependency: transitive description: name: term_glyph - url: "https://pub.dartlang.org" + sha256: a88162591b02c1f3a3db3af8ce1ea2b374bd75a7bb8d5e353bcfbdc79d719830 + url: "https://pub.dev" source: hosted version: "1.2.0" test_api: dependency: transitive description: name: test_api - url: "https://pub.dartlang.org" + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + url: "https://pub.dev" source: hosted - version: "0.4.2" - typed_data: + version: "0.7.6" + vector_math: dependency: transitive description: - name: typed_data - url: "https://pub.dartlang.org" + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" source: hosted - version: "1.3.0" - vector_math: + version: "2.2.0" + vm_service: dependency: transitive description: - name: vector_math - url: "https://pub.dartlang.org" + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "15.0.2" sdks: - dart: ">=2.12.0 <3.0.0" - flutter: ">=1.17.0" + dart: ">=3.8.0-0 <4.0.0" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/image/AllExamples.gif b/image/AllExamples.gif new file mode 100644 index 0000000..616c82d Binary files /dev/null and b/image/AllExamples.gif differ diff --git a/image/AutoNavigationExample.gif b/image/AutoNavigationExample.gif new file mode 100644 index 0000000..619d5e6 Binary files /dev/null and b/image/AutoNavigationExample.gif differ diff --git a/image/BalloonTreeLayout.gif b/image/BalloonTreeLayout.gif new file mode 100644 index 0000000..f181ca3 Binary files /dev/null and b/image/BalloonTreeLayout.gif differ diff --git a/image/CircleLayout.gif b/image/CircleLayout.gif new file mode 100644 index 0000000..33e177a Binary files /dev/null and b/image/CircleLayout.gif differ diff --git a/image/MindMapLayout.gif b/image/MindMapLayout.gif new file mode 100644 index 0000000..a1cb6e0 Binary files /dev/null and b/image/MindMapLayout.gif differ diff --git a/image/NodeExpandCollapseAnimation.gif b/image/NodeExpandCollapseAnimation.gif new file mode 100644 index 0000000..0ebf249 Binary files /dev/null and b/image/NodeExpandCollapseAnimation.gif differ diff --git a/image/RadialTreeLayout.gif b/image/RadialTreeLayout.gif new file mode 100644 index 0000000..a3ccf88 Binary files /dev/null and b/image/RadialTreeLayout.gif differ diff --git a/image/TidierTreeLayout.gif b/image/TidierTreeLayout.gif new file mode 100644 index 0000000..e84c9a6 Binary files /dev/null and b/image/TidierTreeLayout.gif differ diff --git a/lib/Algorithm.dart b/lib/Algorithm.dart index 75056b8..8ae3eee 100644 --- a/lib/Algorithm.dart +++ b/lib/Algorithm.dart @@ -9,11 +9,7 @@ abstract class Algorithm { /// @return The size of the graph Size run(Graph? graph, double shiftX, double shiftY); - void setFocusedNode(Node node); - void init(Graph? graph); - void step(Graph? graph); - void setDimensions(double width, double height); } diff --git a/lib/Graph.dart b/lib/Graph.dart index 9f3d605..9603520 100644 --- a/lib/Graph.dart +++ b/lib/Graph.dart @@ -5,7 +5,13 @@ class Graph { final List _edges = []; List graphObserver = []; - List get nodes => _nodes; // List nodes = _nodes; + // Cache + final Map> _successorCache = {}; + final Map> _predecessorCache = {}; + bool _cacheValid = false; + + List get nodes => _nodes; + List get edges => _edges; var isTree = false; @@ -13,27 +19,24 @@ class Graph { int nodeCount() => _nodes.length; void addNode(Node node) { - // if (!_nodes.contains(node)) { _nodes.add(node); + _cacheValid = false; notifyGraphObserver(); - // } } void addNodes(List nodes) => nodes.forEach((it) => addNode(it)); void removeNode(Node? node) { - if (!_nodes.contains(node)) { -// throw IllegalArgumentException("Unable to find node in graph.") - } + if (!_nodes.contains(node)) return; if (isTree) { successorsOf(node).forEach((element) => removeNode(element)); } _nodes.remove(node); - - _edges.removeWhere((edge) => edge.source == node || edge.destination == node); - + _edges + .removeWhere((edge) => edge.source == node || edge.destination == node); + _cacheValid = false; notifyGraphObserver(); } @@ -42,7 +45,6 @@ class Graph { Edge addEdge(Node source, Node destination, {Paint? paint}) { final edge = Edge(source, destination, paint: paint); addEdgeS(edge); - return edge; } @@ -67,37 +69,68 @@ class Graph { if (!_edges.contains(edge)) { _edges.add(edge); + _cacheValid = false; notifyGraphObserver(); } } void addEdges(List edges) => edges.forEach((it) => addEdgeS(it)); - void removeEdge(Edge edge) => _edges.remove(edge); + void removeEdge(Edge edge) { + _edges.remove(edge); + _cacheValid = false; + } void removeEdges(List edges) => edges.forEach((it) => removeEdge(it)); void removeEdgeFromPredecessor(Node? predecessor, Node? current) { - _edges.removeWhere((edge) => edge.source == predecessor && edge.destination == current); + _edges.removeWhere( + (edge) => edge.source == predecessor && edge.destination == current); + _cacheValid = false; } bool hasNodes() => _nodes.isNotEmpty; Edge? getEdgeBetween(Node source, Node? destination) => - _edges.firstWhereOrNull((element) => element.source == source && element.destination == destination); + _edges.firstWhereOrNull((element) => + element.source == source && element.destination == destination); + + bool hasSuccessor(Node? node) => successorsOf(node).isNotEmpty; - bool hasSuccessor(Node? node) => _edges.any((element) => element.source == node); + List successorsOf(Node? node) { + if (node == null) return []; + if (!_cacheValid) _buildCache(); + return _successorCache[node] ?? []; + } - List successorsOf(Node? node) => getOutEdges(node!).map((e) => e.destination).toList(); + bool hasPredecessor(Node node) => predecessorsOf(node).isNotEmpty; - bool hasPredecessor(Node node) => _edges.any((element) => element.destination == node); + List predecessorsOf(Node? node) { + if (node == null) return []; + if (!_cacheValid) _buildCache(); + return _predecessorCache[node] ?? []; + } - List predecessorsOf(Node? node) => getInEdges(node!).map((edge) => edge.source).toList(); + void _buildCache() { + _successorCache.clear(); + _predecessorCache.clear(); - bool contains({Node? node, Edge? edge}) => - node != null && _nodes.contains(node) || edge != null && _edges.contains(edge); + for (var node in _nodes) { + _successorCache[node] = []; + _predecessorCache[node] = []; + } -// bool contains(Edge edge) => _edges.contains(edge); + for (Edge edge in _edges) { + _successorCache[edge.source]!.add(edge.destination); + _predecessorCache[edge.destination]!.add(edge.source); + } + + _cacheValid = true; + } + + bool contains({Node? node, Edge? edge}) => + node != null && _nodes.contains(node) || + edge != null && _edges.contains(edge); bool containsData(data) => _nodes.any((element) => element.data == data); @@ -115,15 +148,20 @@ class Graph { } @Deprecated('Please use the builder and id mechanism to build the widgets') - Node getNodeAtUsingData(Widget data) => _nodes.firstWhere((element) => element.data == data); + Node getNodeAtUsingData(Widget data) => + _nodes.firstWhere((element) => element.data == data); - Node getNodeUsingKey(ValueKey key) => _nodes.firstWhere((element) => element.key == key); + Node getNodeUsingKey(ValueKey key) => + _nodes.firstWhere((element) => element.key == key); - Node getNodeUsingId(dynamic id) => _nodes.firstWhere((element) => element.key == ValueKey(id)); + Node getNodeUsingId(dynamic id) => + _nodes.firstWhere((element) => element.key == ValueKey(id)); - List getOutEdges(Node node) => _edges.where((element) => element.source == node).toList(); + List getOutEdges(Node node) => + _edges.where((element) => element.source == node).toList(); - List getInEdges(Node node) => _edges.where((element) => element.destination == node).toList(); + List getInEdges(Node node) => + _edges.where((element) => element.destination == node).toList(); void notifyGraphObserver() => graphObserver.forEach((element) { element.notifyGraphInvalidated(); @@ -131,11 +169,12 @@ class Graph { String toJson() { var jsonString = { - 'nodes': [ - ..._nodes.map((e) => e.hashCode.toString()) - ], + 'nodes': [..._nodes.map((e) => e.hashCode.toString())], 'edges': [ - ..._edges.map((e) => {'from': e.source.hashCode.toString(), 'to': e.destination.hashCode.toString()}) + ..._edges.map((e) => { + 'from': e.source.hashCode.toString(), + 'to': e.destination.hashCode.toString() + }) ] }; @@ -144,6 +183,29 @@ class Graph { } +extension GraphExtension on Graph { + Rect calculateGraphBounds() { + var minX = double.infinity; + var minY = double.infinity; + var maxX = double.negativeInfinity; + var maxY = double.negativeInfinity; + + for (final node in nodes) { + minX = min(minX, node.x); + minY = min(minY, node.y); + maxX = max(maxX, node.x + node.width); + maxY = max(maxY, node.y + node.height); + } + + return Rect.fromLTRB(minX, minY, maxX, maxY); + } + + Size calculateGraphSize() { + final bounds = calculateGraphBounds(); + return bounds.size; + } +} + enum LineType { Default, DottedLine, @@ -189,7 +251,8 @@ class Node { } @override - bool operator ==(Object other) => identical(this, other) || other is Node && hashCode == other.hashCode; + bool operator ==(Object other) => + identical(this, other) || other is Node && hashCode == other.hashCode; @override int get hashCode { @@ -212,7 +275,8 @@ class Edge { Edge(this.source, this.destination, {this.key, this.paint}); @override - bool operator ==(Object? other) => identical(this, other) || other is Edge && hashCode == other.hashCode; + bool operator ==(Object? other) => + identical(this, other) || other is Edge && hashCode == other.hashCode; @override int get hashCode => key?.hashCode ?? Object.hash(source, destination); diff --git a/lib/GraphView.dart b/lib/GraphView.dart index 3f9701f..81e4601 100644 --- a/lib/GraphView.dart +++ b/lib/GraphView.dart @@ -2,62 +2,318 @@ library graphview; import 'dart:async'; import 'dart:collection'; -import 'dart:math'; import 'dart:convert'; +import 'dart:math'; + +import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/rendering.dart'; -import 'dart:ui'; -import 'package:collection/collection.dart' show IterableExtension; - -part 'Graph.dart'; +import 'package:vector_math/vector_math_64.dart' hide Colors; part 'Algorithm.dart'; - +part 'Graph.dart'; part 'edgerenderer/ArrowEdgeRenderer.dart'; - part 'edgerenderer/EdgeRenderer.dart'; - +part 'familytree/FamilyTreeAlgorithm.dart'; +part 'familytree/FamilyTreeBuchheimWalkerConfiguration.dart'; +part 'familytree/FamilyTreeEdgeRenderer.dart'; part 'forcedirected/FruchtermanReingoldAlgorithm.dart'; - +part 'forcedirected/FruchtermanReingoldConfiguration.dart'; +part 'layered/EiglspergerAlgorithm.dart'; part 'layered/SugiyamaAlgorithm.dart'; - part 'layered/SugiyamaConfiguration.dart'; - part 'layered/SugiyamaEdgeData.dart'; - part 'layered/SugiyamaEdgeRenderer.dart'; - part 'layered/SugiyamaNodeData.dart'; - +part 'mindmap/MindMapAlgorithm.dart'; +part 'mindmap/MindmapEdgeRenderer.dart'; +part 'tree/BaloonLayoutAlgorithm.dart'; part 'tree/BuchheimWalkerAlgorithm.dart'; - part 'tree/BuchheimWalkerConfiguration.dart'; - part 'tree/BuchheimWalkerNodeData.dart'; - +part 'tree/CircleLayoutAlgorithm.dart'; +part 'tree/RadialTreeLayoutAlgorithm.dart'; +part 'tree/TidierTreeLayoutAlgorithm.dart'; part 'tree/TreeEdgeRenderer.dart'; typedef NodeWidgetBuilder = Widget Function(Node node); +typedef EdgeWidgetBuilder = Widget Function(Edge edge); class GraphViewController { _GraphViewState? _state; final TransformationController? transformationController; - GraphViewController({this.transformationController}); + final Map _collapsedNodes = {}; + final Map _hiddenNodes = {}; + final Map _expandingNodes = {}; + bool _visibilityValid = false; + + Node? _collapsedNode; + Node? focusedNode; + + GraphViewController({ + this.transformationController, + }); - void _attach(_GraphViewState state) => _state = state; + void _attach(_GraphViewState? state) => _state = state; void _detach() => _state = null; - void animateToNode(ValueKey key) => _state?.animateToNode(key); + void animateToNode(ValueKey key) => _state?.jumpToNodeUsingKey(key, true); - void animateToMatrix(Matrix4 targetMatrix) => - _state?.animateToMatrix(targetMatrix); + void jumpToNode(ValueKey key) => _state?.jumpToNodeUsingKey(key, false); + + void animateToMatrix(Matrix4 target) => _state?.animateToMatrix(target); void resetView() => _state?.resetView(); void zoomToFit() => _state?.zoomToFit(); + + void forceRecalculation() => _state?.forceRecalculation(); + + // Visibility management methods + bool isNodeCollapsed(Node node) => _collapsedNodes.containsKey(node); + + bool isNodeHidden(Node node) => _hiddenNodes.containsKey(node); + + bool isNodeVisible(Graph graph, Node node) { + if (!_visibilityValid) _buildVisibilityCache(graph); + return !_hiddenNodes.containsKey(node); + } + + void _buildVisibilityCache(Graph graph) { + _hiddenNodes.clear(); + + void markDescendantsHidden(Node node) { + for (final child in graph.successorsOf(node)) { + _hiddenNodes[child] = true; + markDescendantsHidden(child); // Recursively hide descendants + } + } + + // Find all collapsed nodes and hide their descendants + for (final node in _collapsedNodes.keys) { + markDescendantsHidden(node); + } + + _visibilityValid = true; + } + + void _invalidateVisibilityCache() { + _visibilityValid = false; + } + + Node? findClosestVisibleAncestor(Graph graph, Node node) { + var current = graph.predecessorsOf(node).firstOrNull; + + // Walk up until we find a visible ancestor + while (current != null) { + if (isNodeVisible(graph, current)) { + return current; // Return the first (closest) visible ancestor + } + current = graph.predecessorsOf(current).firstOrNull; + } + + return null; + } + + void _markExpandingDescendants(Graph graph, Node node) { + // Mark all immediate visible children as expanding + for (final child in graph.successorsOf(node)) { + // Only mark as expanding if the child will actually become visible + if (!_collapsedNodes.containsKey(child)) { + _expandingNodes[child] = true; + // Recursively mark descendants if this child is not collapsed + _markExpandingDescendants(graph, child); + } + } + } + + void expandNode(Graph graph, Node node, {animate = false}) { + _collapsedNodes.remove(node); + + _expandingNodes.clear(); + _markExpandingDescendants(graph, node); + + _invalidateVisibilityCache(); + if (animate) { + focusedNode = node; + } + forceRecalculation(); + } + + void collapseNode(Graph graph, Node node, {animate = false}) { + if (graph.hasSuccessor(node)) { + _collapsedNodes[node] = true; + _collapsedNode = node; + if (animate) { + focusedNode = node; + } + _invalidateVisibilityCache(); + forceRecalculation(); + } + _expandingNodes.clear(); + } + + void toggleNodeExpanded(Graph graph, Node node, {animate = false}) { + if (isNodeCollapsed(node)) { + expandNode(graph, node, animate: animate); + } else { + collapseNode(graph, node, animate: animate); + } + } + + List getCollapsingEdges(Graph graph) { + if (_collapsedNode == null) return []; + + final collapsingEdges = []; + final visitedNodes = {}; + + void collectCollapsingEdgesRecursively(Node node) { + if (visitedNodes.containsKey(node)) return; + visitedNodes[node] = true; + + if (_hiddenNodes.containsKey(node) && _collapsedNodes.containsKey(node)) + return; + + // Get all outgoing edges from this node + for (final edge in graph.getOutEdges(node)) { + final destination = edge.destination; + + // Add edge if destination is being hidden (collapsing) + if (_hiddenNodes.containsKey(destination)) { + collapsingEdges.add(edge); + // Recursively collect edges from hidden descendants + collectCollapsingEdgesRecursively(destination); + } + } + } + + // Start collection from the last collapsed node + collectCollapsingEdgesRecursively(_collapsedNode!); + return collapsingEdges; + } + + // Additional convenience methods for setting initial state + void setInitiallyCollapsedNodes(List nodes) { + for (final node in nodes) { + _collapsedNodes[node] = true; + } + _invalidateVisibilityCache(); + } + + void setInitiallyCollapsedByKeys(Graph graph, Set keys) { + for (final key in keys) { + try { + final node = graph.getNodeUsingKey(key); + _collapsedNodes[node] = true; + } catch (e) { + // Node with key not found, ignore + } + } + _invalidateVisibilityCache(); + } + + bool isNodeExpanding(Node node) => _expandingNodes.containsKey(node); + + void removeCollapsingNodes() { + _collapsedNode = null; + } +} + +class GraphChildDelegate { + final Graph graph; + final Algorithm algorithm; + final NodeWidgetBuilder builder; + GraphViewController? controller; + final bool centerGraph; + Graph? _cachedVisibleGraph; + bool _needsRecalculation = true; + + GraphChildDelegate({ + required this.graph, + required this.algorithm, + required this.builder, + required this.controller, + this.centerGraph = false, + }); + + Graph getVisibleGraph() { + if (_cachedVisibleGraph != null && !_needsRecalculation) { + return _cachedVisibleGraph!; + } + + final visibleGraph = Graph(); + for (final edge in graph.edges) { + if (isNodeVisible(edge.source) && isNodeVisible(edge.destination)) { + visibleGraph.addEdgeS(edge); + } + } + + if (controller != null) { + final collapsingEdges = controller!.getCollapsingEdges(graph); + visibleGraph.addEdges(collapsingEdges); + } + + if (visibleGraph.nodes.isEmpty && graph.nodes.isNotEmpty) { + visibleGraph.addNode(graph.nodes.first); + } + + _cachedVisibleGraph = visibleGraph; + _needsRecalculation = false; + return visibleGraph; + } + + Graph getVisibleGraphOnly() { + final visibleGraph = Graph(); + for (final edge in graph.edges) { + if (isNodeVisible(edge.source) && isNodeVisible(edge.destination)) { + visibleGraph.addEdgeS(edge); + } + } + + if (visibleGraph.nodes.isEmpty && graph.nodes.isNotEmpty) { + visibleGraph.addNode(graph.nodes.first); + } + return visibleGraph; + } + + Widget? build(Node node) { + var child = node.data ?? builder(node); + return KeyedSubtree(key: node.key, child: child); + } + + bool shouldRebuild(GraphChildDelegate oldDelegate) { + final result = + graph != oldDelegate.graph || algorithm != oldDelegate.algorithm; + if (result) _needsRecalculation = true; + return result; + } + + Size runAlgorithm() { + final visibleGraph = getVisibleGraphOnly(); + + if (centerGraph) { + // Use large viewport and center the graph + var viewPortSize = Size(200000, 200000); + var centerX = viewPortSize.width / 2; + var centerY = viewPortSize.height / 2; + algorithm.run(visibleGraph, centerX, centerY); + return viewPortSize; + } else { + // Use default algorithm behavior + return algorithm.run(visibleGraph, 0, 0); + } + } + + bool isNodeVisible(Node node) { + return controller?.isNodeVisible(graph, node) ?? true; + } + + Node? findClosestVisibleAncestor(Node node) { + return controller?.findClosestVisibleAncestor(graph, node); + } } class GraphView extends StatefulWidget { @@ -69,6 +325,13 @@ class GraphView extends StatefulWidget { final GraphViewController? controller; final bool _isBuilder; + Duration? panAnimationDuration; + Duration? toggleAnimationDuration; + ValueKey? initialNode; + bool autoZoomToFit = false; + late GraphChildDelegate delegate; + final bool centerGraph; + GraphView({ Key? key, required this.graph, @@ -76,8 +339,15 @@ class GraphView extends StatefulWidget { this.paint, required this.builder, this.animated = true, - }) : controller = null, - _isBuilder = false, + this.controller, + this.toggleAnimationDuration, + this.centerGraph = false, + }) : _isBuilder = false, + delegate = GraphChildDelegate( + graph: graph, + algorithm: algorithm, + builder: builder, + controller: null), super(key: key); GraphView.builder({ @@ -88,7 +358,20 @@ class GraphView extends StatefulWidget { required this.builder, this.controller, this.animated = true, + this.initialNode, + this.autoZoomToFit = false, + this.panAnimationDuration, + this.toggleAnimationDuration, + this.centerGraph = false, }) : _isBuilder = true, + delegate = GraphChildDelegate( + graph: graph, + algorithm: algorithm, + builder: builder, + controller: controller, + centerGraph: centerGraph), + assert(!(autoZoomToFit && initialNode != null), + 'Cannot use both autoZoomToFit and initialNode together. Choose one.'), super(key: key); @override @@ -96,87 +379,110 @@ class GraphView extends StatefulWidget { } class _GraphViewState extends State with TickerProviderStateMixin { - TransformationController _transformationController = TransformationController(); - - // Separate animation controllers - late final AnimationController - _cameraAnimationController; // For camera movements - - Animation? _animationMove; - late final AnimationController _animationController; + late TransformationController _transformationController; + late final AnimationController _cameraController; + late final AnimationController _nodeController; + Animation? _cameraAnimation; @override void initState() { super.initState(); - if (widget.controller?.transformationController != null) { - _transformationController.dispose(); - _transformationController = widget.controller!.transformationController!; - } - // Initialize camera animation controller (for animateToNode, zoomToFit, etc.) - _cameraAnimationController = AnimationController( + _transformationController = widget.controller?.transformationController ?? + TransformationController(); + + _cameraController = AnimationController( vsync: this, - duration: const Duration(milliseconds: 800), + duration: + widget.panAnimationDuration ?? const Duration(milliseconds: 600), ); + + _nodeController = AnimationController( + vsync: this, + duration: + widget.toggleAnimationDuration ?? const Duration(milliseconds: 600), + ); + widget.controller?._attach(this); + + if (widget.autoZoomToFit || widget.initialNode != null) { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (widget.autoZoomToFit) { + zoomToFit(); + } else if (widget.initialNode != null) { + jumpToNodeUsingKey(widget.initialNode!, false); + } + }); + } } @override void dispose() { widget.controller?._detach(); - _cameraAnimationController.dispose(); - - if (widget.controller?.transformationController == null) { - _transformationController.dispose(); - } + _cameraController.dispose(); + _nodeController.dispose(); + _transformationController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { - final graphView = _GraphView( - graph: widget.graph, - algorithm: widget.algorithm, + final view = GraphViewWidget( paint: widget.paint, - builder: widget.builder, + nodeAnimationController: _nodeController, + enableAnimation: widget.animated, + delegate: widget.delegate, ); if (widget._isBuilder) { - return InteractiveViewer( - transformationController: _transformationController, - constrained: false, - boundaryMargin: EdgeInsets.all(double.infinity), - minScale: 0.01, - maxScale: 5.6, - child: graphView, - ); + return InteractiveViewer.builder( + transformationController: _transformationController, + boundaryMargin: EdgeInsets.all(double.infinity), + minScale: 0.01, + maxScale: 10, + builder: (BuildContext context, Quad viewport) { + return view; + }); } - return graphView; + return view; } - void animateToNode(ValueKey key) { - final node = widget.graph.nodes.cast().firstWhere( - (n) => n?.key == key, - orElse: () => null, - ); + void jumpToNodeUsingKey(ValueKey key, bool animated) { + final node = widget.graph.nodes.firstWhereOrNull((n) => n.key == key); if (node == null) return; + jumpToNode(node, animated); + } + + void jumpToNode(Node node, bool animated) { + final nodeCenter = Offset( + node.position.dx + node.width / 2, node.position.dy + node.height / 2); + + jumpToOffset(nodeCenter, animated); + } + + void jumpToOffset(Offset offset, bool animated) { final renderBox = context.findRenderObject() as RenderBox?; if (renderBox == null) return; - final viewportSize = renderBox.size; - final centerX = viewportSize.width / 2; - final centerY = viewportSize.height / 2; - final nodeCenter = Offset( - node.position.dx + node.width / 2, - node.position.dy + node.height / 2, - ); + final viewport = renderBox.size; + final center = Offset(viewport.width / 2, viewport.height / 2); + + final currentScale = _transformationController.value.getMaxScaleOnAxis(); + + final scaledNodeCenter = offset * currentScale; + final translation = center - scaledNodeCenter; - final targetMatrix = Matrix4.identity() - ..translate(centerX - nodeCenter.dx, centerY - nodeCenter.dy); + final target = Matrix4.identity() + ..translate(translation.dx, translation.dy) + ..scale(currentScale); - animateToMatrix(targetMatrix); + if (animated) { + animateToMatrix(target); + } else { + _transformationController.value = target; + } } void resetView() => animateToMatrix(Matrix4.identity()); @@ -186,203 +492,693 @@ class _GraphViewState extends State with TickerProviderStateMixin { final renderBox = context.findRenderObject() as RenderBox?; if (renderBox == null) return; - final viewportSize = renderBox.size; - final bounds = _calculateGraphBounds(); - - final scale = (viewportSize.shortestSide * 0.8 / bounds.longestSide).clamp(0.01, 5.6); - final centerOffset = Offset( - viewportSize.width / 2 - bounds.center.dx * scale, - viewportSize.height / 2 - bounds.center.dy * scale, - ); + final vp = renderBox.size; + final bounds = widget.graph.calculateGraphBounds(); + final scale = (vp.shortestSide * 0.95 / bounds.longestSide); + final centerOffset = Offset(vp.width * 0.5 - bounds.center.dx * scale, + vp.height * 0.5 - bounds.center.dy * scale); - final targetMatrix = Matrix4.identity() + final target = Matrix4.identity() ..translate(centerOffset.dx, centerOffset.dy) ..scale(scale); + animateToMatrix(target); + } + + void animateToMatrix(Matrix4 target) { + _cameraController.reset(); + _cameraAnimation = Matrix4Tween( + begin: _transformationController.value, end: target) + .animate( + CurvedAnimation(parent: _cameraController, curve: Curves.linear)); + _cameraAnimation!.addListener(_onCameraTick); + _cameraController.forward(); + } + + void _onCameraTick() { + if (_cameraAnimation == null) return; + _transformationController.value = _cameraAnimation!.value; + if (!_cameraController.isAnimating) { + _cameraAnimation!.removeListener(_onCameraTick); + _cameraAnimation = null; + _cameraController.reset(); + } + } + + void forceRecalculation() { + // Invalidate the delegate's cached graph + widget.delegate._needsRecalculation = true; - animateToMatrix(targetMatrix); + setState(() {}); } +} + +abstract class GraphChildManager { + void startLayout(); - Rect _calculateGraphBounds() { - if (widget.graph.nodes.isEmpty) return Rect.zero; + void buildChild(Node node); - final positions = widget.graph.nodes.map((n) => n.position); - final left = positions.map((p) => p.dx).reduce((a, b) => a < b ? a : b); - final top = positions.map((p) => p.dy).reduce((a, b) => a < b ? a : b); - final right = positions.map((p) => p.dx + 100).reduce((a, b) => a > b ? a : b); - final bottom = positions.map((p) => p.dy + 50).reduce((a, b) => a > b ? a : b); + void reuseChild(Node node); - return Rect.fromLTRB(left, top, right, bottom); + void endLayout(); +} + +class GraphViewWidget extends RenderObjectWidget { + final GraphChildDelegate delegate; + final Paint? paint; + final AnimationController nodeAnimationController; + final bool enableAnimation; + + const GraphViewWidget({ + Key? key, + required this.delegate, + this.paint, + required this.nodeAnimationController, + required this.enableAnimation, + }) : super(key: key); + + @override + GraphViewElement createElement() => GraphViewElement(this); + + @override + RenderCustomLayoutBox createRenderObject(BuildContext context) { + return RenderCustomLayoutBox( + delegate, + paint, + enableAnimation, + nodeAnimationController: nodeAnimationController, + childManager: context as GraphChildManager, + ); + } + + @override + void updateRenderObject( + BuildContext context, RenderCustomLayoutBox renderObject) { + renderObject + ..delegate = delegate + ..edgePaint = paint + ..nodeAnimationController = nodeAnimationController + ..enableAnimation = enableAnimation; } +} + +class GraphViewElement extends RenderObjectElement + implements GraphChildManager { + GraphViewElement(GraphViewWidget super.widget); - void animateToMatrix(Matrix4 targetMatrix) { - _cameraAnimationController.reset(); - _animationMove = Matrix4Tween( - begin: _transformationController.value, - end: targetMatrix, - ).animate(CurvedAnimation( - parent: _cameraAnimationController, - curve: Curves.easeInOut, - )); - _animationMove!.addListener(_onAnimateMove); - _cameraAnimationController.forward(); + @override + GraphViewWidget get widget => super.widget as GraphViewWidget; + + @override + RenderCustomLayoutBox get renderObject => + super.renderObject as RenderCustomLayoutBox; + + // Contains all children, including those that are keyed + Map _nodeToElement = {}; + Map _keyToElement = {}; + + // Used between startLayout() & endLayout() to compute the new values + Map? _newNodeToElement; + Map? _newKeyToElement; + + bool get _debugIsDoingLayout => + _newNodeToElement != null && _newKeyToElement != null; + + @override + void performRebuild() { + super.performRebuild(); + // Children list is updated during layout since we only know during layout + // which children will be visible + renderObject.markNeedsLayout(); } - void _onAnimateMove() { - _transformationController.value = _animationMove!.value; - if (!_cameraAnimationController.isAnimating) { - _animationMove!.removeListener(_onAnimateMove); - _animationMove = null; - _cameraAnimationController.reset(); + @override + void forgetChild(Element child) { + assert(!_debugIsDoingLayout); + super.forgetChild(child); + _nodeToElement.remove(child.slot as Node); + if (child.widget.key != null) { + _keyToElement.remove(child.widget.key); } } -} + @override + void insertRenderObjectChild(RenderBox child, Node slot) { + renderObject._insertChild(child, slot); + } -class _GraphView extends MultiChildRenderObjectWidget { - final Graph graph; - final Algorithm algorithm; - final Paint? paint; + @override + void moveRenderObjectChild(RenderBox child, Node oldSlot, Node newSlot) { + renderObject._moveChild(child, from: oldSlot, to: newSlot); + } - _GraphView({Key? key, required this.graph, required this.algorithm, this.paint, required NodeWidgetBuilder builder}) - : super(key: key, children: _extractChildren(graph, builder)) { - assert(() { - if (children.isEmpty) { - throw FlutterError( - 'Children must not be empty, ensure you are overriding the builder', - ); - } + @override + void removeRenderObjectChild(RenderBox child, Node slot) { + renderObject._removeChild(child, slot); + } - return true; - }()); + @override + void visitChildren(ElementVisitor visitor) { + _nodeToElement.values.forEach(visitor); } - // Traverses the nodes depth-first collects the list of child widgets that are created. - static List _extractChildren(Graph graph, NodeWidgetBuilder builder) { - final result = []; + // ---- GraphChildManager implementation ---- - graph.nodes.forEach((node) { - var widget = node.data ?? builder(node); - result.add(widget); - }); + @override + void startLayout() { + assert(!_debugIsDoingLayout); + _newNodeToElement = {}; + _newKeyToElement = {}; + } - return result; + @override + void buildChild(Node node) { + assert(_debugIsDoingLayout); + owner!.buildScope(this, () { + final newWidget = widget.delegate.build(node); + if (newWidget == null) { + return; + } + + final oldElement = _retrieveOldElement(newWidget, node); + final newChild = updateChild(oldElement, newWidget, node); + + if (newChild != null) { + // Ensure we are not overwriting an existing child + assert(_newNodeToElement![node] == null); + _newNodeToElement![node] = newChild; + if (newWidget.key != null) { + // Ensure we are not overwriting an existing key + assert(_newKeyToElement![newWidget.key!] == null); + _newKeyToElement![newWidget.key!] = newChild; + } + } + }); } @override - RenderCustomLayoutBox createRenderObject(BuildContext context) { - return RenderCustomLayoutBox(graph, algorithm, paint); + void reuseChild(Node node) { + assert(_debugIsDoingLayout); + final elementToReuse = _nodeToElement.remove(node); + assert( + elementToReuse != null, + 'Expected to re-use an element at $node, but none was found.', + ); + _newNodeToElement![node] = elementToReuse!; + if (elementToReuse.widget.key != null) { + assert(_keyToElement.containsKey(elementToReuse.widget.key)); + assert(_keyToElement[elementToReuse.widget.key] == elementToReuse); + _newKeyToElement![elementToReuse.widget.key!] = + _keyToElement.remove(elementToReuse.widget.key)!; + } + } + + Element? _retrieveOldElement(Widget newWidget, Node node) { + if (newWidget.key != null) { + final result = _keyToElement.remove(newWidget.key); + if (result != null) { + _nodeToElement.remove(result.slot as Node); + } + return result; + } + + final potentialOldElement = _nodeToElement[node]; + if (potentialOldElement != null && potentialOldElement.widget.key == null) { + return _nodeToElement.remove(node); + } + return null; } @override - void updateRenderObject(BuildContext context, RenderCustomLayoutBox renderObject) { - renderObject - ..graph = graph - ..algorithm = algorithm - ..edgePaint = paint; + void endLayout() { + assert(_debugIsDoingLayout); + + // Unmount all elements that have not been reused in the layout cycle + for (final element in _nodeToElement.values) { + if (element.widget.key == null) { + // If it has a key, we handle it below + updateChild(element, null, null); + } else { + assert(_keyToElement.containsValue(element)); + } + } + for (final element in _keyToElement.values) { + assert(element.widget.key != null); + updateChild(element, null, null); + } + + _nodeToElement = _newNodeToElement!; + _keyToElement = _newKeyToElement!; + _newNodeToElement = null; + _newKeyToElement = null; + assert(!_debugIsDoingLayout); + + centerNodeWhileToggling(); + } + + void centerNodeWhileToggling() { + if (widget.delegate.controller?.focusedNode != null) { + widget.delegate.controller?._state?.jumpToOffset( + widget.delegate.controller!.focusedNode!.position, true); + widget.delegate.controller?.focusedNode = null; + } + widget.delegate.controller?.removeCollapsingNodes(); } } class RenderCustomLayoutBox extends RenderBox - with ContainerRenderObjectMixin, RenderBoxContainerDefaultsMixin { - late Graph _graph; - late Algorithm _algorithm; + with + ContainerRenderObjectMixin, + RenderBoxContainerDefaultsMixin { late Paint _paint; + late AnimationController _nodeAnimationController; + late GraphChildDelegate _delegate; + GraphChildManager? childManager; + + Size? _cachedSize; + bool _isInitialized = false; + bool _needsFullRecalculation = false; + late bool enableAnimation; + final opacityPaint = Paint(); + + final animatedPositions = {}; + final _children = {}; + final _activeChildrenForLayoutPass = {}; RenderCustomLayoutBox( - Graph graph, - Algorithm algorithm, - Paint? paint, { - List? children, + GraphChildDelegate delegate, + Paint? paint, + bool enableAnimation, { + required AnimationController nodeAnimationController, + this.childManager, }) { - _algorithm = algorithm; - _graph = graph; + _nodeAnimationController = nodeAnimationController; + _delegate = delegate; edgePaint = paint; - addAll(children); + this.enableAnimation = enableAnimation; + } + + RenderBox? buildOrObtainChildFor(Node node) { + assert(debugDoingThisLayout); + + if (_needsFullRecalculation || !_children.containsKey(node)) { + invokeLayoutCallback((BoxConstraints _) { + childManager!.buildChild(node); + }); + } else { + childManager!.reuseChild(node); + } + + if (!_children.containsKey(node)) { + // There is no child for this node, the delegate may not provide one + return null; + } + + assert(_children.containsKey(node)); + final child = _children[node]!; + _activeChildrenForLayoutPass[node] = child; + return child; + } + + GraphChildDelegate get delegate => _delegate; + + Graph get graph => _delegate.getVisibleGraph(); + + Algorithm get algorithm => _delegate.algorithm; + + set delegate(GraphChildDelegate value) { + // if (value != _delegate) { + _needsFullRecalculation = true; + _isInitialized = false; + _delegate = value; + markNeedsLayout(); + // } + } + + void markNeedsRecalculation() { + _needsFullRecalculation = false; + _isInitialized = false; + markNeedsLayout(); + } + + @override + void attach(PipelineOwner owner) { + super.attach(owner); + _nodeAnimationController.addListener(_onAnimationTick); + for (final child in _children.values) { + child.attach(owner); + } + } + + @override + void detach() { + _nodeAnimationController.removeListener(_onAnimationTick); + super.detach(); + for (final child in _children.values) { + child.detach(); + } + } + + void forceRecalculation() { + _needsFullRecalculation = true; + _isInitialized = false; + markNeedsLayout(); } Paint get edgePaint => _paint; set edgePaint(Paint? value) { - _paint = value ?? + final newPaint = value ?? (Paint() ..color = Colors.black ..strokeWidth = 3) ..style = PaintingStyle.stroke ..strokeCap = StrokeCap.butt; + + _paint = newPaint; markNeedsPaint(); } - Graph get graph => _graph; + AnimationController get nodeAnimationController => _nodeAnimationController; - set graph(Graph value) { - _graph = value; + set nodeAnimationController(AnimationController value) { + if (identical(_nodeAnimationController, value)) return; + _nodeAnimationController.removeListener(_onAnimationTick); + _nodeAnimationController = value; + _nodeAnimationController.addListener(_onAnimationTick); markNeedsLayout(); } - Algorithm get algorithm => _algorithm; - - set algorithm(Algorithm value) { - _algorithm = value; - markNeedsLayout(); + void _onAnimationTick() { + markNeedsPaint(); } @override - void setupParentData(RenderBox child) { - if (child.parentData is! NodeBoxData) { - child.parentData = NodeBoxData(); + void paint(PaintingContext context, Offset offset) { + if (_children.isEmpty) return; + + if (enableAnimation) { + final t = _nodeAnimationController.value; + animatedPositions.clear(); + + for (final entry in _children.entries) { + final node = entry.key; + final child = entry.value; + final nodeData = child.parentData as NodeBoxData; + + final isExpanding = + _delegate.controller?.isNodeExpanding(node) ?? false; + if (isExpanding) { + final parent = graph.predecessorsOf(node).firstOrNull; + final pos = + Offset.lerp(animatedPositions[parent], nodeData.targetOffset, t)!; + animatedPositions[node] = pos; + } else { + final pos = + Offset.lerp(nodeData.startOffset, nodeData.targetOffset, t)!; + + animatedPositions[node] = pos; + } + } + + context.canvas.save(); + context.canvas.translate(offset.dx, offset.dy); + algorithm.renderer?.setAnimatedPositions(animatedPositions); + algorithm.renderer?.render(context.canvas, graph, edgePaint); + context.canvas.restore(); + + _paintNodes(context, offset, t); + } else { + context.canvas.save(); + context.canvas.translate(offset.dx, offset.dy); + algorithm.renderer?.render(context.canvas, graph, edgePaint); + context.canvas.restore(); + + for (final entry in _children.entries) { + final node = entry.key; + final child = entry.value; + + if (_delegate.isNodeVisible(node)) { + context.paintChild(child, offset + node.position); + } + } } } @override void performLayout() { - if (childCount == 0) { - size = constraints.biggest; - assert(size.isFinite); - return; + _activeChildrenForLayoutPass.clear(); + childManager!.startLayout(); + + final looseConstraints = BoxConstraints.loose(constraints.biggest); + + if (_needsFullRecalculation || !_isInitialized) { + _layoutNodesLazily(looseConstraints); + _cachedSize = _delegate.runAlgorithm(); + _isInitialized = true; + _needsFullRecalculation = false; } - var child = firstChild; - var position = 0; - var looseConstraints = BoxConstraints.loose(constraints.biggest); - while (child != null) { - final node = child.parentData as NodeBoxData; + size = _cachedSize ?? Size.zero; - child.layout(looseConstraints, parentUsesSize: true); - graph.getNodeAtPosition(position).size = child.size; + invokeLayoutCallback((BoxConstraints _) { + childManager!.endLayout(); + }); - child = node.nextSibling; - position++; + if (enableAnimation) { + _updateAnimationStates(); + } else { + _updateNodePositions(); } + } - size = algorithm.run(graph, 0, 0); + void _paintNodes(PaintingContext context, Offset offset, double t) { + for (final entry in _children.entries) { + final node = entry.key; + final child = entry.value; + final nodeData = child.parentData as NodeBoxData; + final pos = animatedPositions[node]!; + + final isVisible = _delegate.isNodeVisible(node); + if (isVisible) { + final isExpanding = + _delegate.controller?.isNodeExpanding(node) ?? false; + if (_nodeAnimationController.isAnimating && isExpanding) { + _paintExpandingNode(context, child, offset, pos, t); + } else { + context.paintChild(child, offset + pos); + } + } else { + if (_nodeAnimationController.isAnimating && + nodeData.startOffset != nodeData.targetOffset) { + _paintCollapsingNode(context, child, offset, pos, t); + } else if (_nodeAnimationController.isCompleted) { + nodeData.startOffset = nodeData.targetOffset; + } + } - child = firstChild; - position = 0; - while (child != null) { - final node = child.parentData as NodeBoxData; + if (_nodeAnimationController.isCompleted) { + nodeData.offset = node.position; + } + } + } - node.offset = graph.getNodeAtPosition(position).position; + void _paintExpandingNode(PaintingContext context, RenderBox child, + Offset offset, Offset pos, double t) { + final progress = t.clamp(0.0, 1.0); + final opacity = progress; // Fade in as it expands + final center = + pos + offset + Offset(child.size.width * 0.5, child.size.height * 0.5); - child = node.nextSibling; - position++; - } + context.canvas.save(); + + // Apply scaling from center + context.canvas.translate(center.dx, center.dy); + context.canvas.scale(progress, progress); + context.canvas.translate(-center.dx, -center.dy); + + // Paint with opacity using saveLayer + opacityPaint + ..color = Color.fromRGBO(255, 255, 255, opacity) + ..colorFilter = ColorFilter.mode( + Colors.white.withOpacity(opacity), BlendMode.modulate); + + context.canvas.saveLayer( + Rect.fromLTWH(pos.dx + offset.dx - 20, pos.dy + offset.dy - 20, + child.size.width + 40, child.size.height + 40), + opacityPaint); + + context.paintChild(child, offset + pos); + + context.canvas.restore(); // Restore saveLayer + context.canvas.restore(); // Restore main save } - @override - void paint(PaintingContext context, Offset offset) { + void _paintCollapsingNode(PaintingContext context, RenderBox child, + Offset offset, Offset pos, double t) { + final progress = (1.0 - t).clamp(0.0, 1.0); + final opacity = progress; // Fade out as it collapses + final center = + pos + offset + Offset(child.size.width * 0.5, child.size.height * 0.5); + context.canvas.save(); - context.canvas.translate(offset.dx, offset.dy); - algorithm.renderer?.render(context.canvas, graph, edgePaint); + // Apply scaling from center + context.canvas.translate(center.dx, center.dy); + context.canvas.scale(progress, progress); + context.canvas.translate(-center.dx, -center.dy); + + // Paint with opacity using saveLayer + opacityPaint + ..color = Color.fromRGBO(255, 255, 255, opacity) + ..colorFilter = ColorFilter.mode( + Colors.white.withOpacity(opacity), BlendMode.modulate); + + context.canvas.saveLayer( + Rect.fromLTWH(pos.dx + offset.dx - 20, pos.dy + offset.dy - 20, + child.size.width + 40, child.size.height + 40), + opacityPaint); + + context.paintChild(child, offset + pos); + + context.canvas.restore(); // Restore saveLayer + context.canvas.restore(); // Restore main save + } + + void _updateNodePositions() { + for (final entry in _children.entries) { + final node = entry.key; + final child = entry.value; + final nodeData = child.parentData as NodeBoxData; + + if (_delegate.isNodeVisible(node)) { + nodeData.offset = node.position; + } else { + final parent = delegate.findClosestVisibleAncestor(node); + nodeData.offset = parent?.position ?? node.position; + } + } + } + + void _layoutNodesLazily(BoxConstraints constraints) { + for (final node in graph.nodes) { + final child = buildOrObtainChildFor(node); + if (child != null) { + child.layout(constraints, parentUsesSize: true); + node.size = Size(child.size.width.ceilToDouble(), child.size.height); + } + } + } + + void _updateAnimationStates() { + for (final entry in _children.entries) { + final node = entry.key; + final child = entry.value; + final nodeData = child.parentData as NodeBoxData; + final isVisible = _delegate.isNodeVisible(node); + + if (isVisible) { + _updateVisibleNodeAnimation(nodeData, node); + } else { + _updateCollapsedNodeAnimation(nodeData, node); + } + } + + _nodeAnimationController.reset(); + _nodeAnimationController.forward(); + } - context.canvas.restore(); + void _updateVisibleNodeAnimation(NodeBoxData nodeData, Node graphNode) { + final prevTarget = nodeData.targetOffset; + var newPos = graphNode.position; + + if (prevTarget == null) { + final parent = graph.predecessorsOf(graphNode).firstOrNull; + nodeData.startOffset = parent?.position ?? newPos; + nodeData.targetOffset = newPos; + } else if (prevTarget != newPos) { + nodeData.startOffset = prevTarget; + nodeData.targetOffset = newPos; + } else { + nodeData.startOffset = newPos; + nodeData.targetOffset = newPos; + } + } - defaultPaint(context, offset); + void _updateCollapsedNodeAnimation(NodeBoxData nodeData, Node graphNode) { + final parent = delegate.findClosestVisibleAncestor(graphNode); + final parentPos = parent?.position ?? Offset.zero; + + final prevTarget = nodeData.targetOffset; + + if (nodeData.startOffset == nodeData.targetOffset) { + nodeData.targetOffset = parentPos; + } else if (prevTarget != null && prevTarget != parentPos) { + // Just collapsed now → animate toward parent + nodeData.startOffset = graphNode.position; + nodeData.targetOffset = parentPos; + } else { + // animation finished → lock to parent + nodeData.startOffset = parentPos; + nodeData.targetOffset = parentPos; + } } @override bool hitTestChildren(BoxHitTestResult result, {required Offset position}) { - return defaultHitTestChildren(result, position: position); + if (enableAnimation && !_nodeAnimationController.isCompleted) return false; + + for (final entry in _children.entries) { + final node = entry.key; + + if (delegate.isNodeVisible(node)) { + final child = entry.value; + + final childParentData = child.parentData as BoxParentData; + final isHit = result.addWithPaintOffset( + offset: childParentData.offset, + position: position, + hitTest: (BoxHitTestResult result, Offset transformed) { + return child.hitTest(result, position: transformed); + }, + ); + if (isHit) return true; + } + } + return false; + } + + @override + void setupParentData(RenderBox child) { + if (child.parentData is! NodeBoxData) { + child.parentData = NodeBoxData(); + } + } + + // ---- Called from GraphViewElement ---- + void _insertChild(RenderBox child, Node slot) { + _children[slot] = child; + adoptChild(child); + } + + void _moveChild(RenderBox child, {required Node from, required Node to}) { + if (_children[from] == child) { + _children.remove(from); + } + _children[to] = child; + } + + void _removeChild(RenderBox child, Node slot) { + if (_children[slot] == child) { + _children.remove(slot); + } + dropChild(child); + } + + @override + void visitChildren(RenderObjectVisitor visitor) { + for (final child in _children.values) { + visitor(child); + } } @override @@ -394,27 +1190,34 @@ class RenderCustomLayoutBox extends RenderBox } } -class NodeBoxData extends ContainerBoxParentData {} +class NodeBoxData extends ContainerBoxParentData { + Offset? startOffset; + Offset? targetOffset; +} -class _GraphViewAnimated extends StatefulWidget { +class GraphViewCustomPainter extends StatefulWidget { final Graph graph; - final Algorithm algorithm; + final FruchtermanReingoldAlgorithm algorithm; final Paint? paint; final NodeWidgetBuilder builder; final stepMilis = 25; - _GraphViewAnimated( - {Key? key, required this.graph, required this.algorithm, this.paint, required this.builder}) { - } + GraphViewCustomPainter({ + Key? key, + required this.graph, + required this.algorithm, + this.paint, + required this.builder, + }) : super(key: key); @override - _GraphViewAnimatedState createState() => _GraphViewAnimatedState(); + _GraphViewCustomPainterState createState() => _GraphViewCustomPainterState(); } -class _GraphViewAnimatedState extends State<_GraphViewAnimated> { +class _GraphViewCustomPainterState extends State { late Timer timer; late Graph graph; - late Algorithm algorithm; + late FruchtermanReingoldAlgorithm algorithm; @override void initState() { @@ -442,19 +1245,21 @@ class _GraphViewAnimatedState extends State<_GraphViewAnimated> { @override Widget build(BuildContext context) { - algorithm.setDimensions(MediaQuery.of(context).size.width, MediaQuery.of(context).size.height); + algorithm.setDimensions( + MediaQuery.of(context).size.width, MediaQuery.of(context).size.height); return Stack( clipBehavior: Clip.none, children: [ CustomPaint( size: MediaQuery.of(context).size, - painter: EdgeRender(algorithm, graph, Offset(20, 20)), + painter: EdgeRender(algorithm, graph, Offset(20, 20), widget.paint), ), ...List.generate(graph.nodeCount(), (index) { return Positioned( child: GestureDetector( - child: graph.nodes[index].data ?? widget.builder(graph.nodes[index]), + child: + graph.nodes[index].data ?? widget.builder(graph.nodes[index]), onPanUpdate: (details) { graph.getNodeAtPosition(index).position += details.delta; update(); @@ -477,16 +1282,18 @@ class EdgeRender extends CustomPainter { Algorithm algorithm; Graph graph; Offset offset; + Paint? customPaint; - EdgeRender(this.algorithm, this.graph, this.offset); + EdgeRender(this.algorithm, this.graph, this.offset, this.customPaint); @override void paint(Canvas canvas, Size size) { - var edgePaint = (Paint() - ..color = Colors.black - ..strokeWidth = 3) - ..style = PaintingStyle.stroke - ..strokeCap = StrokeCap.butt; + var edgePaint = customPaint ?? + (Paint() + ..color = Colors.black + ..strokeWidth = 3 + ..style = PaintingStyle.stroke + ..strokeCap = StrokeCap.butt); canvas.save(); canvas.translate(offset.dx, offset.dy); @@ -499,4 +1306,4 @@ class EdgeRender extends CustomPainter { bool shouldRepaint(CustomPainter oldDelegate) { return true; } -} +} \ No newline at end of file diff --git a/lib/edgerenderer/ArrowEdgeRenderer.dart b/lib/edgerenderer/ArrowEdgeRenderer.dart index 35d85d7..48a7594 100644 --- a/lib/edgerenderer/ArrowEdgeRenderer.dart +++ b/lib/edgerenderer/ArrowEdgeRenderer.dart @@ -5,10 +5,16 @@ const double ARROW_LENGTH = 10; class ArrowEdgeRenderer extends EdgeRenderer { var trianglePath = Path(); + final bool noArrow; + + ArrowEdgeRenderer({this.noArrow = false}); Offset _getNodeCenter(Node node) { final nodePosition = getNodePosition(node); - return Offset(nodePosition.dx + node.width * 0.5, nodePosition.dy + node.height * 0.5); + return Offset( + nodePosition.dx + node.width * 0.5, + nodePosition.dy + node.height * 0.5, + ); } @override @@ -18,20 +24,45 @@ class ArrowEdgeRenderer extends EdgeRenderer { ..style = PaintingStyle.fill; graph.edges.forEach((edge) { - var source = edge.source; - var destination = edge.destination; - - var sourceOffset = getNodePosition(source); - var destinationOffset = getNodePosition(destination); - - var startX = sourceOffset.dx + source.width * 0.5; - var startY = sourceOffset.dy + source.height * 0.5; - var stopX = destinationOffset.dx + destination.width * 0.5; - var stopY = destinationOffset.dy + destination.height * 0.5; - - var clippedLine = clipLineEnd(startX, startY, stopX, stopY, destinationOffset.dx, - destinationOffset.dy, destination.width, destination.height); + renderEdge(canvas, edge, paint, trianglePaint); + }); + } + void renderEdge(Canvas canvas, Edge edge, Paint paint, Paint trianglePaint) { + var source = edge.source; + var destination = edge.destination; + + var sourceOffset = getNodePosition(source); + var destinationOffset = getNodePosition(destination); + + var startX = sourceOffset.dx + source.width * 0.5; + var startY = sourceOffset.dy + source.height * 0.5; + var stopX = destinationOffset.dx + destination.width * 0.5; + var stopY = destinationOffset.dy + destination.height * 0.5; + + var clippedLine = clipLineEnd( + startX, + startY, + stopX, + stopY, + destinationOffset.dx, + destinationOffset.dy, + destination.width, + destination.height); + + final currentPaint = edge.paint ?? paint; + + if (noArrow) { + // Draw line without arrow, respecting line type + drawStyledLine( + canvas, + Offset(clippedLine[0], clippedLine[1]), + Offset(clippedLine[2], clippedLine[3]), + currentPaint, + lineType: _getLineType(destination), + ); + } else { + // Draw line with arrow Paint? edgeTrianglePaint; if (edge.paint != null) { edgeTrianglePaint = Paint() @@ -47,30 +78,48 @@ class ArrowEdgeRenderer extends EdgeRenderer { clippedLine[2], clippedLine[3]); - canvas.drawLine( - Offset(clippedLine[0], clippedLine[1]), - triangleCentroid, - edge.paint ?? paint); - }); + // Draw the line with the appropriate style + drawStyledLine( + canvas, + Offset(clippedLine[0], clippedLine[1]), + triangleCentroid, + currentPaint, + lineType: _getLineType(destination), + ); + } } - Offset drawTriangle( - Canvas canvas, Paint paint, double lineStartX, double lineStartY, double arrowTipX, double arrowTipY) { + /// Helper to get line type from node data if available + LineType? _getLineType(Node node) { + // This assumes you have a way to access node data + // You may need to adjust this based on your actual implementation + if (node is SugiyamaNodeData) { + return node.lineType; + } + return null; + } + Offset drawTriangle(Canvas canvas, Paint paint, double lineStartX, + double lineStartY, double arrowTipX, double arrowTipY) { // Calculate direction from line start to arrow tip, then flip 180° to point backwards from tip - var lineDirection = (atan2(arrowTipY - lineStartY, arrowTipX - lineStartX) + pi); + var lineDirection = + (atan2(arrowTipY - lineStartY, arrowTipX - lineStartX) + pi); // Calculate the two base points of the arrowhead triangle - var leftWingX = (arrowTipX + ARROW_LENGTH * cos((lineDirection - ARROW_DEGREES))); - var leftWingY = (arrowTipY + ARROW_LENGTH * sin((lineDirection - ARROW_DEGREES))); - var rightWingX = (arrowTipX + ARROW_LENGTH * cos((lineDirection + ARROW_DEGREES))); - var rightWingY = (arrowTipY + ARROW_LENGTH * sin((lineDirection + ARROW_DEGREES))); + var leftWingX = + (arrowTipX + ARROW_LENGTH * cos((lineDirection - ARROW_DEGREES))); + var leftWingY = + (arrowTipY + ARROW_LENGTH * sin((lineDirection - ARROW_DEGREES))); + var rightWingX = + (arrowTipX + ARROW_LENGTH * cos((lineDirection + ARROW_DEGREES))); + var rightWingY = + (arrowTipY + ARROW_LENGTH * sin((lineDirection + ARROW_DEGREES))); // Draw the triangle: tip -> left wing -> right wing -> back to tip - trianglePath.moveTo(arrowTipX, arrowTipY); // Arrow tip - trianglePath.lineTo(leftWingX, leftWingY); // Left wing - trianglePath.lineTo(rightWingX, rightWingY); // Right wing - trianglePath.close(); // Back to tip + trianglePath.moveTo(arrowTipX, arrowTipY); // Arrow tip + trianglePath.lineTo(leftWingX, leftWingY); // Left wing + trianglePath.lineTo(rightWingX, rightWingY); // Right wing + trianglePath.close(); // Back to tip canvas.drawPath(trianglePath, paint); // Calculate center point of the triangle @@ -107,7 +156,7 @@ class ArrowEdgeRenderer extends EdgeRenderer { if (halfSlopeWidth.abs() <= halfHeight) { if (destX > startX) { // Left edge intersection - return [startX, startY,stopX - halfWidth, stopY - halfSlopeWidth]; + return [startX, startY, stopX - halfWidth, stopY - halfSlopeWidth]; } else if (destX < startX) { // Right edge intersection return [startX, startY, stopX + halfWidth, stopY + halfSlopeWidth]; diff --git a/lib/edgerenderer/EdgeRenderer.dart b/lib/edgerenderer/EdgeRenderer.dart index 5b2c73d..e616b22 100644 --- a/lib/edgerenderer/EdgeRenderer.dart +++ b/lib/edgerenderer/EdgeRenderer.dart @@ -1,29 +1,141 @@ part of graphview; abstract class EdgeRenderer { - Offset getNodePosition(Node node) => node.position; + Map? _animatedPositions; + + void setAnimatedPositions(Map positions) => _animatedPositions = positions; + + Offset getNodePosition(Node node) => _animatedPositions?[node] ?? node.position; void render(Canvas canvas, Graph graph, Paint paint); -} - -void _drawDottedLine(Canvas canvas, Offset start, Offset end, Paint paint) { - var dottedPaint = Paint() - ..color = paint.color - ..strokeWidth = paint.strokeWidth - ..style = PaintingStyle.stroke; - - var distance = (end - start).distance; - var dashLength = 10; - var gapLength = 10; - var totalLength = dashLength + gapLength; - - final path = Path()..moveTo(start.dx, start.dy); - for (var i = 0; i < distance; i += totalLength) { - path.lineTo(start.dx + (i + dashLength) * (end.dx - start.dx) / distance, - start.dy + (i + dashLength) * (end.dy - start.dy) / distance); - path.moveTo(start.dx + (i + totalLength) * (end.dx - start.dx) / distance, - start.dy + (i + totalLength) * (end.dy - start.dy) / distance); + + Offset getNodeCenter(Node node) { + final nodePosition = getNodePosition(node); + return Offset( + nodePosition.dx + node.width * 0.5, + nodePosition.dy + node.height * 0.5, + ); + } + + /// Draws a line between two points respecting the node's line type + void drawStyledLine(Canvas canvas, Offset start, Offset end, Paint paint, + {LineType? lineType}) { + switch (lineType) { + case LineType.DashedLine: + drawDashedLine(canvas, start, end, paint, 0.6); + break; + case LineType.DottedLine: + drawDashedLine(canvas, start, end, paint, 0.0); + break; + case LineType.SineLine: + drawSineLine(canvas, start, end, paint); + break; + default: + canvas.drawLine(start, end, paint); + break; + } + } + + /// Draws a styled path respecting the node's line type + void drawStyledPath(Canvas canvas, Path path, Paint paint, + {LineType? lineType}) { + if (lineType == null || lineType == LineType.Default) { + canvas.drawPath(path, paint); + } else { + // For non-solid lines, we need to convert the path to segments + // This is a simplified approach - for complex paths with curves, + // you might need a more sophisticated solution + canvas.drawPath(path, paint); + } + } + + /// Draws a dashed line between two points + void drawDashedLine(Canvas canvas, Offset source, Offset destination, + Paint paint, double lineLength) { + final dx = destination.dx - source.dx; + final dy = destination.dy - source.dy; + final distance = sqrt(dx * dx + dy * dy); + + if (distance == 0) return; + + final numLines = lineLength == 0.0 ? (distance / 5).ceil() : 14; + final stepX = dx / numLines; + final stepY = dy / numLines; + + if (lineLength == 0.0) { + // Draw dots + final circleRadius = 1.0; + final circlePaint = Paint() + ..color = paint.color + ..strokeWidth = 1.0 + ..style = PaintingStyle.fill; + + for (var i = 0; i < numLines; i++) { + final x = source.dx + (i * stepX); + final y = source.dy + (i * stepY); + canvas.drawCircle(Offset(x, y), circleRadius, circlePaint); + } + } else { + // Draw dashes + for (var i = 0; i < numLines; i++) { + final startX = source.dx + (i * stepX); + final startY = source.dy + (i * stepY); + final endX = startX + (stepX * lineLength); + final endY = startY + (stepY * lineLength); + canvas.drawLine(Offset(startX, startY), Offset(endX, endY), paint); + } + } } - canvas.drawPath(path, dottedPaint); -} + /// Draws a sine wave line between two points + void drawSineLine(Canvas canvas, Offset source, Offset destination, Paint paint) { + final originalStrokeWidth = paint.strokeWidth; + paint.strokeWidth = 1.5; + + final dx = destination.dx - source.dx; + final dy = destination.dy - source.dy; + final distance = sqrt(dx * dx + dy * dy); + + if (distance == 0 || (dx == 0 && dy == 0)) { + paint.strokeWidth = originalStrokeWidth; + return; + } + + const lineLength = 6.0; + const phaseOffset = 2.0; + var distanceTraveled = 0.0; + var phase = 0.0; + + final path = Path()..moveTo(source.dx, source.dy); + var currentSource = source; + + while (distanceTraveled < distance) { + final segmentLength = min(lineLength, distance - distanceTraveled); + final segmentFraction = (distanceTraveled + segmentLength) / distance; + final segmentDestination = Offset( + source.dx + dx * segmentFraction, + source.dy + dy * segmentFraction, + ); + + final waveAmplitude = sin(phase + phaseOffset) * segmentLength; + + double perpX, perpY; + if ((dx > 0 && dy < 0) || (dx < 0 && dy > 0)) { + perpX = waveAmplitude; + perpY = waveAmplitude; + } else { + perpX = -waveAmplitude; + perpY = waveAmplitude; + } + + path.lineTo(segmentDestination.dx + perpX, segmentDestination.dy + perpY); + + distanceTraveled += segmentLength; + currentSource = segmentDestination; + phase += pi * segmentLength / lineLength; + } + + canvas.drawPath(path, paint); + paint.strokeWidth = originalStrokeWidth; + } +} \ No newline at end of file diff --git a/lib/forcedirected/FruchtermanReingoldAlgorithm.dart b/lib/forcedirected/FruchtermanReingoldAlgorithm.dart index 7e8b284..4a17edb 100644 --- a/lib/forcedirected/FruchtermanReingoldAlgorithm.dart +++ b/lib/forcedirected/FruchtermanReingoldAlgorithm.dart @@ -1,136 +1,204 @@ part of graphview; -const int DEFAULT_ITERATIONS = 1000; -const double REPULSION_RATE = 0.5; -const double REPULSION_PERCENTAGE = 0.4; -const double ATTRACTION_RATE = 0.15; -const double ATTRACTION_PERCENTAGE = 0.15; -const int CLUSTER_PADDING = 15; -const double EPSILON = 0.0001; - class FruchtermanReingoldAlgorithm implements Algorithm { + static const double DEFAULT_TICK_FACTOR = 0.1; + static const double CONVERGENCE_THRESHOLD = 1.0; + Map displacement = {}; + Map nodeRects = {}; Random rand = Random(); double graphHeight = 500; //default value, change ahead of time double graphWidth = 500; late double tick; - int iterations = DEFAULT_ITERATIONS; - double repulsionRate = REPULSION_RATE; - double attractionRate = ATTRACTION_RATE; - double repulsionPercentage = REPULSION_PERCENTAGE; - double attractionPercentage = ATTRACTION_PERCENTAGE; + FruchtermanReingoldConfiguration configuration; @override EdgeRenderer? renderer; - FruchtermanReingoldAlgorithm( - {this.iterations = DEFAULT_ITERATIONS, - this.renderer, - this.repulsionRate = REPULSION_RATE, - this.attractionRate = ATTRACTION_RATE, - this.repulsionPercentage = REPULSION_PERCENTAGE, - this.attractionPercentage = ATTRACTION_PERCENTAGE}) { - renderer = renderer ?? ArrowEdgeRenderer(); + FruchtermanReingoldAlgorithm(this.configuration, {this.renderer}) { + this.configuration = configuration; + this.renderer = renderer ?? ArrowEdgeRenderer(noArrow: true); } @override void init(Graph? graph) { graph!.nodes.forEach((node) { displacement[node] = Offset.zero; - node.position = Offset(rand.nextDouble() * graphWidth, rand.nextDouble() * graphHeight); - }); - } - - @override - void step(Graph? graph) { - displacement = {}; - graph!.nodes.forEach((node) { - displacement[node] = Offset.zero; + nodeRects[node] = Rect.fromLTWH(node.x, node.y, node.width, node.height); + + if (configuration.shuffleNodes) { + node.position = Offset( + rand.nextDouble() * graphWidth, rand.nextDouble() * graphHeight); + // Update cached rect after position change + nodeRects[node] = + Rect.fromLTWH(node.x, node.y, node.width, node.height); + } }); - calculateRepulsion(graph.nodes); - calculateAttraction(graph.edges); - moveNodes(graph); } void moveNodes(Graph graph) { + final lerpFactor = configuration.lerpFactor; + graph.nodes.forEach((node) { - var newPosition = node.position += displacement[node]!; - double newDX = min(graphWidth - 40, max(0, newPosition.dx)); - double newDY = min(graphHeight - 40, max(0, newPosition.dy)); + final nodeDisplacement = displacement[node]!; + var target = node.position + nodeDisplacement; + var newPosition = Offset.lerp(node.position, target, lerpFactor)!; + double newDX = min(graphWidth - node.size.width * 0.5, + max(node.size.width * 0.5, newPosition.dx)); + double newDY = min(graphHeight - node.size.height * 0.5, + max(node.size.height * 0.5, newPosition.dy)); - // double newDX = newPosition.dx; - // double newDY = newPosition.dy; node.position = Offset(newDX, newDY); + // Update cached rect after position change + nodeRects[node] = Rect.fromLTWH(node.x, node.y, node.width, node.height); }); } void cool(int currentIteration) { - tick *= 1.0 - currentIteration / iterations; + // tick *= 1.0 - currentIteration / configuration.iterations; + const alpha = 0.99; // tweakable decay factor (0.8–0.99 typical) + tick *= alpha; } void limitMaximumDisplacement(List nodes) { + final epsilon = configuration.epsilon; + nodes.forEach((node) { - if (node != focusedNode) { - var dispLength = max(EPSILON, displacement[node]!.distance); - node.position += displacement[node]! / dispLength * min(dispLength, tick); - } else { - displacement[node] = Offset.zero; - } + final nodeDisplacement = displacement[node]!; + var dispLength = max(epsilon, nodeDisplacement.distance); + node.position += nodeDisplacement / dispLength * min(dispLength, tick); + // Update cached rect after position change + nodeRects[node] = Rect.fromLTWH(node.x, node.y, node.width, node.height); }); } void calculateAttraction(List edges) { - edges.forEach((edge) { + final attractionRate = configuration.attractionRate; + final epsilon = configuration.epsilon; + + // Optimal distance (k) based on area and node count + final k = sqrt((graphWidth * graphHeight) / (edges.length + 1)); + + for (var edge in edges) { var source = edge.source; var destination = edge.destination; var delta = source.position - destination.position; - var deltaDistance = max(EPSILON, delta.distance); - var maxAttractionDistance = min(graphWidth * attractionPercentage, graphHeight * attractionPercentage); - var attractionForce = min(0, (maxAttractionDistance - deltaDistance)).abs() / (maxAttractionDistance * 2); - var attractionVector = delta * attractionForce * attractionRate; + var deltaDistance = max(epsilon, delta.distance); + + // Standard FR attraction: proportional to distance² / k + var attractionForce = (deltaDistance * deltaDistance) / k; + var attractionVector = + delta / deltaDistance * attractionForce * attractionRate; displacement[source] = displacement[source]! - attractionVector; displacement[destination] = displacement[destination]! + attractionVector; - }); + } } void calculateRepulsion(List nodes) { - nodes.forEach((nodeA) { - nodes.forEach((nodeB) { - if (nodeA != nodeB) { - var delta = nodeA.position - nodeB.position; - var deltaDistance = max(EPSILON, delta.distance); //protect for 0 - var maxRepulsionDistance = min(graphWidth * repulsionPercentage, graphHeight * repulsionPercentage); - var repulsionForce = max(0, maxRepulsionDistance - deltaDistance) / maxRepulsionDistance; //value between 0-1 + final repulsionRate = configuration.repulsionRate; + final repulsionPercentage = configuration.repulsionPercentage; + final epsilon = configuration.epsilon; + final nodeCountDouble = nodes.length.toDouble(); + final maxRepulsionDistance = min( + graphWidth * repulsionPercentage, graphHeight * repulsionPercentage); + + for (var i = 0; i < nodeCountDouble; i++) { + final currentNode = nodes[i]; + + for (var j = i + 1; j < nodeCountDouble; j++) { + final otherNode = nodes[j]; + if (currentNode != otherNode) { + // Calculate distance between node rectangles, not just centers + var delta = _getNodeRectDistance(currentNode, otherNode); + var deltaDistance = max(epsilon, delta.distance); //protect for 0 + var repulsionForce = max(0, maxRepulsionDistance - deltaDistance) / + maxRepulsionDistance; //value between 0-1 var repulsionVector = delta * repulsionForce * repulsionRate; - displacement[nodeA] = displacement[nodeA]! + repulsionVector; + displacement[currentNode] = + displacement[currentNode]! + repulsionVector; + displacement[otherNode] = displacement[otherNode]! - repulsionVector; } - }); - }); + } + } + } - nodes.forEach((nodeA) { - displacement[nodeA] = displacement[nodeA]! / nodes.length.toDouble(); - }); + // Calculate closest distance vector between two node rectangles using cached rects + Offset _getNodeRectDistance(Node nodeA, Node nodeB) { + final rectA = nodeRects[nodeA]!; + final rectB = nodeRects[nodeB]!; + + final centerA = rectA.center; + final centerB = rectB.center; + + if (rectA.overlaps(rectB)) { + // Push overlapping nodes apart by at least half their combined size + final dx = + (centerA.dx - centerB.dx).sign * (rectA.width / 2 + rectB.width / 2); + final dy = (centerA.dy - centerB.dy).sign * + (rectA.height / 2 + rectB.height / 2); + return Offset(dx, dy); + } + + // Non-overlapping: distance along nearest edges + final dx = (centerA.dx < rectB.left) + ? (rectB.left - rectA.right) + : (centerA.dx > rectB.right) + ? (rectA.left - rectB.right) + : 0.0; + + final dy = (centerA.dy < rectB.top) + ? (rectB.top - rectA.bottom) + : (centerA.dy > rectB.bottom) + ? (rectA.top - rectB.bottom) + : 0.0; + + return Offset(dx == 0 ? centerA.dx - centerB.dx : dx, + dy == 0 ? centerA.dy - centerB.dy : dy); } - var focusedNode; + bool step(Graph graph) { + var moved = false; + displacement = {}; + for (var node in graph.nodes) { + displacement[node] = Offset.zero; + } + + calculateRepulsion(graph.nodes); + calculateAttraction(graph.edges); + + for (var node in graph.nodes) { + final nodeDisplacement = displacement[node]!; + if (nodeDisplacement.distance > configuration.movementThreshold) { + moved = true; + } + } + + moveNodes(graph); + return moved; + } @override Size run(Graph? graph, double shiftX, double shiftY) { - var size = findBiggestSize(graph!) * graph.nodeCount(); + if (graph == null) { + return Size.zero; + } + var size = findBiggestSize(graph) * graph.nodeCount(); graphWidth = size; graphHeight = size; var nodes = graph.nodes; var edges = graph.edges; - tick = 0.1 * sqrt(graphWidth / 2 * graphHeight / 2); + tick = DEFAULT_TICK_FACTOR * sqrt(graphWidth / 2 * graphHeight / 2); - init(graph); + if (graph.nodes.any((node) => node.position == Offset.zero)) { + init(graph); + } - for (var i = 0; i < iterations; i++) { + for (var i = 0; i < configuration.iterations; i++) { calculateRepulsion(nodes); calculateAttraction(edges); limitMaximumDisplacement(nodes); @@ -142,18 +210,18 @@ class FruchtermanReingoldAlgorithm implements Algorithm { } } - if (focusedNode == null) { - positionNodes(graph); - } + positionNodes(graph); shiftCoordinates(graph, shiftX, shiftY); - return calculateGraphSize(graph); + return graph.calculateGraphSize(); } void shiftCoordinates(Graph graph, double shiftX, double shiftY) { graph.nodes.forEach((node) { node.position = Offset(node.x + shiftX, node.y + shiftY); + // Update cached rect after position change + nodeRects[node] = Rect.fromLTWH(node.x, node.y, node.width, node.height); }); } @@ -165,6 +233,8 @@ class FruchtermanReingoldAlgorithm implements Algorithm { var nodeClusters = []; graph.nodes.forEach((node) { node.position = Offset(node.x - x, node.y - y); + // Update cached rect after position change + nodeRects[node] = Rect.fromLTWH(node.x, node.y, node.width, node.height); }); graph.nodes.forEach((node) { @@ -193,7 +263,9 @@ class FruchtermanReingoldAlgorithm implements Algorithm { for (var i = 1; i < nodeClusters.length; i++) { var nextCluster = nodeClusters[i]; - var xDiff = nextCluster.rect!.left - cluster.rect!.right - CLUSTER_PADDING; + var xDiff = nextCluster.rect!.left - + cluster.rect!.right - + configuration.clusterPadding; var yDiff = nextCluster.rect!.top - cluster.rect!.top; nextCluster.offset(-xDiff, -yDiff); cluster = nextCluster; @@ -216,7 +288,8 @@ class FruchtermanReingoldAlgorithm implements Algorithm { nodeClusters.removeWhere((element) => element.size() == 1); } - void followEdges(Graph graph, NodeCluster cluster, Node node, List nodesVisited) { + void followEdges( + Graph graph, NodeCluster cluster, Node node, List nodesVisited) { graph.successorsOf(node).forEach((successor) { if (!nodesVisited.contains(successor)) { nodesVisited.add(successor); @@ -257,30 +330,11 @@ class FruchtermanReingoldAlgorithm implements Algorithm { } bool done() { - return tick < 1.0 / max(graphHeight, graphWidth); + return tick < CONVERGENCE_THRESHOLD / max(graphHeight, graphWidth); } void drawEdges(Canvas canvas, Graph graph, Paint linePaint) {} - Size calculateGraphSize(Graph graph) { - var left = double.infinity; - var top = double.infinity; - var right = double.negativeInfinity; - var bottom = double.negativeInfinity; - - graph.nodes.forEach((node) { - left = min(left, node.x); - top = min(top, node.y); - right = max(right, node.x + node.width); - bottom = max(bottom, node.y + node.height); - }); - - return Size(right - left, bottom - top); - } - - @override - void setFocusedNode(Node node) {} - @override void setDimensions(double width, double height) { graphWidth = width; @@ -289,11 +343,10 @@ class FruchtermanReingoldAlgorithm implements Algorithm { } class NodeCluster { - List? nodes; - + List nodes; Rect? rect; - List? getNodes() { + List getNodes() { return nodes; } @@ -301,46 +354,52 @@ class NodeCluster { return rect; } - void setRect(Rect rect) { - rect = rect; + void setRect(Rect newRect) { + this.rect = newRect; } void add(Node node) { - nodes!.add(node); + nodes.add(node); - if (nodes!.length == 1) { - rect = Rect.fromLTRB(node.x, node.y, node.x + node.width, node.y + node.height); + if (nodes.length == 1) { + rect = Rect.fromLTRB( + node.x, node.y, node.x + node.width, node.y + node.height); } else { - rect = Rect.fromLTRB(min(rect!.left, node.x), min(rect!.top, node.y), max(rect!.right, node.x + node.width), + rect = Rect.fromLTRB( + min(rect!.left, node.x), + min(rect!.top, node.y), + max(rect!.right, node.x + node.width), max(rect!.bottom, node.y + node.height)); } } bool contains(Node node) { - return nodes!.contains(node); + return nodes.contains(node); } int size() { - return nodes!.length; + return nodes.length; } void concat(NodeCluster cluster) { - cluster.nodes!.forEach((node) { - node.position = (Offset(rect!.right + CLUSTER_PADDING, rect!.top)); + cluster.nodes.forEach((node) { + node.position = (Offset( + rect!.right + + FruchtermanReingoldConfiguration.DEFAULT_CLUSTER_PADDING, + rect!.top)); add(node); }); } void offset(double xDiff, double yDiff) { - nodes!.forEach((node) { + nodes.forEach((node) { node.position = (node.position + Offset(xDiff, yDiff)); }); rect = rect!.translate(xDiff, yDiff); } - NodeCluster() { - nodes = []; - rect = Rect.zero; - } + NodeCluster() + : nodes = [], + rect = Rect.zero; } diff --git a/lib/forcedirected/FruchtermanReingoldConfiguration.dart b/lib/forcedirected/FruchtermanReingoldConfiguration.dart new file mode 100644 index 0000000..37e7d7a --- /dev/null +++ b/lib/forcedirected/FruchtermanReingoldConfiguration.dart @@ -0,0 +1,38 @@ +part of graphview; + +class FruchtermanReingoldConfiguration { + static const int DEFAULT_ITERATIONS = 100; + static const double DEFAULT_REPULSION_RATE = 0.2; + static const double DEFAULT_REPULSION_PERCENTAGE = 0.4; + static const double DEFAULT_ATTRACTION_RATE = 0.15; + static const double DEFAULT_ATTRACTION_PERCENTAGE = 0.15; + static const int DEFAULT_CLUSTER_PADDING = 15; + static const double DEFAULT_EPSILON = 0.0001; + static const double DEFAULT_LERP_FACTOR = 0.05; + static const double DEFAULT_MOVEMENT_THRESHOLD = 0.6; + + int iterations; + double repulsionRate; + double repulsionPercentage; + double attractionRate; + double attractionPercentage; + int clusterPadding; + double epsilon; + double lerpFactor; + double movementThreshold; + bool shuffleNodes = true; + + FruchtermanReingoldConfiguration({ + this.iterations = DEFAULT_ITERATIONS, + this.repulsionRate = DEFAULT_REPULSION_RATE, + this.attractionRate = DEFAULT_ATTRACTION_RATE, + this.repulsionPercentage = DEFAULT_REPULSION_PERCENTAGE, + this.attractionPercentage = DEFAULT_ATTRACTION_PERCENTAGE, + this.clusterPadding = DEFAULT_CLUSTER_PADDING, + this.epsilon = DEFAULT_EPSILON, + this.lerpFactor = DEFAULT_LERP_FACTOR, + this.movementThreshold = DEFAULT_MOVEMENT_THRESHOLD, + this.shuffleNodes = true + }); + +} \ No newline at end of file diff --git a/lib/layered/EiglspergerAlgorithm.dart b/lib/layered/EiglspergerAlgorithm.dart new file mode 100644 index 0000000..3784754 --- /dev/null +++ b/lib/layered/EiglspergerAlgorithm.dart @@ -0,0 +1,1543 @@ +part of graphview; + +class ContainerX { + List segments = []; + int index = -1; + int pos = -1; + double measure = -1; + + ContainerX(); + + void append(Segment segment) { + segments.add(segment); + } + + void join(ContainerX other) { + segments.addAll(other.segments); + other.segments.clear(); + } + + int size() => segments.length; + + bool contains(Segment segment) => segments.contains(segment); + + bool get isEmpty => segments.length == 0; + + static ContainerX createEmpty() => ContainerX(); + + // Split container at segment position + static ContainerPair split(ContainerX container, Segment key) { + final index = container.segments.indexOf(key); + if (index == -1) { + return ContainerPair(container, ContainerX()); + } + + final leftSegments = container.segments.sublist(0, index); + final rightSegments = container.segments.sublist(index + 1); + + final leftContainer = ContainerX(); + leftContainer.segments = leftSegments; + + final rightContainer = ContainerX(); + rightContainer.segments = rightSegments; + + return ContainerPair(leftContainer, rightContainer); + } + + // Split container at position + static ContainerPair splitAt(ContainerX container, int position) { + if (position <= 0) { + return ContainerPair(ContainerX(), container); + } + if (position >= container.size()) { + return ContainerPair(container, ContainerX()); + } + + final leftSegments = container.segments.sublist(0, position); + final rightSegments = container.segments.sublist(position); + + final leftContainer = ContainerX(); + leftContainer.segments = leftSegments; + + final rightContainer = ContainerX(); + rightContainer.segments = rightSegments; + + return ContainerPair(leftContainer, rightContainer); + } + + @override + String toString() => 'Container(${segments.length} segments, pos: $pos, measure: $measure)'; +} + +class ContainerPair { + final ContainerX left; + final ContainerX right; + + ContainerPair(this.left, this.right); +} + +// Segment represents a vertical edge span between P and Q vertices +class Segment { + final Node pVertex; // top vertex (P-vertex) + final Node qVertex; // bottom vertex (Q-vertex) + int index = -1; + final int id; + + static int _nextId = 0; + + Segment(this.pVertex, this.qVertex) : id = _nextId++; + + @override + bool operator ==(Object other) => identical(this, other); + + @override + int get hashCode => id; + + @override + String toString() => 'Segment($id)'; +} + +class EiglspergerNodeData { + bool isDummy = false; + bool isPVertex = false; + bool isQVertex = false; + Segment? segment; + int layer = -1; + int position = -1; + int rank = -1; + double measure = -1; + Set reversed = {}; + List predecessorNodes = []; + List successorNodes = []; + LineType lineType; + + EiglspergerNodeData(this.lineType); + + bool get isSegmentVertex => isPVertex || isQVertex; + bool get isReversed => reversed.isNotEmpty; +} + +class EiglspergerEdgeData { + List bendPoints = []; +} + +// Virtual edge for container connections +class VirtualEdge { + final dynamic source; + final dynamic target; + final int weight; + + VirtualEdge(this.source, this.target, this.weight); + + @override + String toString() => 'VirtualEdge($source -> $target, weight: $weight)'; +} + +// Layer element that can be either a Node or Container +abstract class LayerElement { + int index = -1; + int pos = -1; + double measure = -1; +} + +// Node wrapper for layer elements +class NodeElement extends LayerElement { + final Node node; + NodeElement(this.node); + + @override + String toString() => 'NodeElement(${node.toString()})'; +} + +// Container wrapper for layer elements +class ContainerElement extends LayerElement { + final ContainerX container; + ContainerElement(this.container); + + @override + String toString() => 'ContainerElement(${container.toString()})'; +} + +class EiglspergerAlgorithm extends Algorithm { + Map nodeData = {}; + Map _edgeData = {}; + Set stack = {}; + Set visited = {}; + List> layers = []; + List segments = []; + Set typeOneConflicts = {}; + late Graph graph; + SugiyamaConfiguration configuration; + + @override + EdgeRenderer? renderer; + + var nodeCount = 1; + + EiglspergerAlgorithm(this.configuration) { + // renderer = SugiyamaEdgeRenderer(nodeData, edgeData, configuration.bendPointShape, configuration.addTriangleToEdge); + } + + int get dummyId => 'Dummy ${nodeCount++}'.hashCode; + + bool isVertical() { + var orientation = configuration.orientation; + return orientation == SugiyamaConfiguration.ORIENTATION_TOP_BOTTOM || + orientation == SugiyamaConfiguration.ORIENTATION_BOTTOM_TOP; + } + + bool needReverseOrder() { + var orientation = configuration.orientation; + return orientation == SugiyamaConfiguration.ORIENTATION_BOTTOM_TOP || + orientation == SugiyamaConfiguration.ORIENTATION_RIGHT_LEFT; + } + + @override + Size run(Graph? graph, double shiftX, double shiftY) { + this.graph = copyGraph(graph!); + reset(); + initNodeData(); + cycleRemoval(); + layerAssignment(); + nodeOrdering(); // Eiglsperger 6-step process + coordinateAssignment(); + shiftCoordinates(shiftX, shiftY); + final graphSize = graph.calculateGraphSize(); + denormalize(); + restoreCycle(); + return graphSize; + } + + void shiftCoordinates(double shiftX, double shiftY) { + layers.forEach((List arrayList) { + arrayList.forEach((it) { + it!.position = Offset(it.x + shiftX, it.y + shiftY); + }); + }); + } + + void reset() { + layers.clear(); + stack.clear(); + visited.clear(); + nodeData.clear(); + _edgeData.clear(); + segments.clear(); + typeOneConflicts.clear(); + nodeCount = 1; + } + + void initNodeData() { + graph.nodes.forEach((node) { + node.position = Offset(0, 0); + nodeData[node] = EiglspergerNodeData(node.lineType); + }); + + graph.edges.forEach((edge) { + _edgeData[edge] = EiglspergerEdgeData(); + }); + + graph.edges.forEach((edge) { + nodeData[edge.source]?.successorNodes.add(edge.destination); + nodeData[edge.destination]?.predecessorNodes.add(edge.source); + }); + } + + void cycleRemoval() { + graph.nodes.forEach((node) { + dfs(node); + }); + } + + void dfs(Node node) { + if (visited.contains(node)) { + return; + } + visited.add(node); + stack.add(node); + graph.getOutEdges(node).forEach((edge) { + final target = edge.destination; + if (stack.contains(target)) { + graph.removeEdge(edge); + graph.addEdge(target, node); + nodeData[node]!.reversed.add(target); + } else { + dfs(target); + } + }); + stack.remove(node); + } + + void layerAssignment() { + if (graph.nodes.isEmpty) { + return; + } + + // Build layers using topological sort + final copiedGraph = copyGraph(graph); + var roots = getRootNodes(copiedGraph); + + while (roots.isNotEmpty) { + layers.add(roots); + copiedGraph.removeNodes(roots); + roots = getRootNodes(copiedGraph); + } + + // Create segments for long edges + createSegmentsForLongEdges(); + } + + void createSegmentsForLongEdges() { + // Create segments for edges spanning more than one layer + for (var i = 0; i < layers.length - 1; i++) { + var currentLayer = layers[i]; + + for (var node in List.from(currentLayer)) { + final edges = graph.getOutEdges(node) + .where((e) => (nodeData[e.destination]!.layer - nodeData[node]!.layer).abs() > 1) + .toList(); + + for (var edge in edges) { + if (nodeData[edge.destination]!.layer - nodeData[node]!.layer == 2) { + // Simple case: only one layer between source and target + createSingleDummyVertex(edge, i + 1); + } else { + // Complex case: multiple layers between source and target + createSegment(edge); + } + graph.removeEdge(edge); + } + } + } + } + + void createSingleDummyVertex(Edge edge, int dummyLayer) { + final dummy = Node.Id(dummyId); + + final dummyData = EiglspergerNodeData(edge.source.lineType); + dummyData.isDummy = true; + dummyData.layer = dummyLayer; + nodeData[dummy] = dummyData; + + dummy.size = Size(edge.source.width, 0); + + layers[dummyLayer].add(dummy); + graph.addNode(dummy); + + final edge1 = graph.addEdge(edge.source, dummy); + final edge2 = graph.addEdge(dummy, edge.destination); + + _edgeData[edge1] = EiglspergerEdgeData(); + _edgeData[edge2] = EiglspergerEdgeData(); + } + + void createSegment(Edge edge) { + final sourceLayer = nodeData[edge.source]!.layer; + final targetLayer = nodeData[edge.destination]!.layer; + + // Create P-vertex (top of segment) + final pVertex = Node.Id(dummyId); + final pData = EiglspergerNodeData(edge.source.lineType); + pData.isDummy = true; + pData.isPVertex = true; + pData.layer = sourceLayer + 1; + nodeData[pVertex] = pData; + pVertex.size = Size(edge.source.width, 0); + + // Create Q-vertex (bottom of segment) + final qVertex = Node.Id(dummyId); + final qData = EiglspergerNodeData(edge.source.lineType); + qData.isDummy = true; + qData.isQVertex = true; + qData.layer = targetLayer - 1; + nodeData[qVertex] = qData; + qVertex.size = Size(edge.source.width, 0); + + // Create segment and link vertices + final segment = Segment(pVertex, qVertex); + pData.segment = segment; + qData.segment = segment; + segments.add(segment); + + // Add to layers and graph + layers[sourceLayer + 1].add(pVertex); + layers[targetLayer - 1].add(qVertex); + graph.addNode(pVertex); + graph.addNode(qVertex); + + // Create edges + final edgeToP = graph.addEdge(edge.source, pVertex); + final segmentEdge = graph.addEdge(pVertex, qVertex); + final edgeFromQ = graph.addEdge(qVertex, edge.destination); + + _edgeData[edgeToP] = EiglspergerEdgeData(); + _edgeData[segmentEdge] = EiglspergerEdgeData(); + _edgeData[edgeFromQ] = EiglspergerEdgeData(); + } + + List getRootNodes(Graph graph) { + final predecessors = {}; + graph.edges.forEach((element) { + predecessors[element.destination] = true; + }); + + var roots = graph.nodes.where((node) => predecessors[node] == null); + roots.forEach((node) { + nodeData[node]?.layer = layers.length; + }); + + return roots.toList(); + } + + Graph copyGraph(Graph graph) { + final copy = Graph(); + copy.addNodes(graph.nodes); + copy.addEdges(graph.edges); + return copy; + } + + void nodeOrdering() { + final best = >[...layers]; + + // Precalculate neighbor information + + + var bestCrossCount = double.infinity; + + for (var i = 0; i < configuration.iterations; i++) { + var crossCount = 0.0; + + if (i % 2 == 0) { + crossCount = forwardSweep(layers); + } else { + crossCount = backwardSweep(layers); + } + + if (crossCount < bestCrossCount) { + bestCrossCount = crossCount; + // Save best configuration + for (var layerIndex = 0; layerIndex < layers.length; layerIndex++) { + best[layerIndex] = List.from(layers[layerIndex]); + } + } + + if (crossCount == 0) break; + } + + // Restore best configuration + for (var layerIndex = 0; layerIndex < layers.length; layerIndex++) { + layers[layerIndex] = best[layerIndex]; + } + + // Set final positions + updateNodePositions(); + } + + double forwardSweep(List> layers) { + var totalCrossings = 0.0; + + for (var i = 0; i < layers.length - 1; i++) { + var currentLayer = layers[i]; + var nextLayer = layers[i + 1]; + + // Convert to layer elements with containers + var currentElements = createLayerElements(currentLayer); + var nextElements = createLayerElements(nextLayer); + + // Eiglsperger 6-step process + stepOne(currentElements, true); // Handle P-vertices + stepTwo(currentElements, nextElements); + stepThree(nextElements); + stepFour(nextElements, i + 1); + totalCrossings += stepFive(currentElements, nextElements, i, i + 1); + stepSix(nextElements); + + // Convert back to node layer + layers[i + 1] = extractNodes(nextElements); + } + + return totalCrossings; + } + + double backwardSweep(List> layers) { + var totalCrossings = 0.0; + + for (var i = layers.length - 1; i > 0; i--) { + var currentLayer = layers[i]; + var prevLayer = layers[i - 1]; + + var currentElements = createLayerElements(currentLayer); + var prevElements = createLayerElements(prevLayer); + + stepOne(currentElements, false); // Handle Q-vertices + stepTwo(currentElements, prevElements); + stepThree(prevElements); + stepFour(prevElements, i - 1); + totalCrossings += stepFive(currentElements, prevElements, i, i - 1); + stepSix(prevElements); + + layers[i - 1] = extractNodes(prevElements); + } + + return totalCrossings; + } + + List createLayerElements(List layer) { + return layer.map((node) => NodeElement(node)).cast().toList(); + } + + List extractNodes(List elements) { + var nodes = []; + for (var element in elements) { + if (element is NodeElement) { + nodes.add(element.node); + } else if (element is ContainerElement) { + // Extract nodes from segments in container + for (var segment in element.container.segments) { + if (!nodes.contains(segment.pVertex)) { + nodes.add(segment.pVertex); + } + if (!nodes.contains(segment.qVertex)) { + nodes.add(segment.qVertex); + } + } + } + } + return nodes; + } + + // Eiglsperger Step 1: Handle P-vertices (forward) or Q-vertices (backward) + void stepOne(List layer, bool isForward) { + var processedElements = []; + ContainerX? currentContainer; + + for (var element in layer) { + if (element is NodeElement) { + var node = element.node; + var data = nodeData[node]; + + bool shouldMerge = isForward ? + (data?.isPVertex ?? false) : + (data?.isQVertex ?? false); + + if (shouldMerge && data?.segment != null) { + // Merge into container + currentContainer ??= ContainerX(); + currentContainer.append(data!.segment!); + + if (!processedElements.any((e) => e is ContainerElement && e.container == currentContainer)) { + processedElements.add(ContainerElement(currentContainer!)); + } + } else { + // Regular node + processedElements.add(element); + currentContainer = null; + } + } else { + processedElements.add(element); + currentContainer = null; + } + } + + layer.clear(); + layer.addAll(processedElements); + } + + // Eiglsperger Step 2: Compute position values and measures + void stepTwo(List currentLayer, List nextLayer) { + // Assign positions to current layer + assignPositions(currentLayer); + + // Compute measures for next layer based on current layer positions + for (var element in nextLayer) { + if (element is NodeElement) { + var node = element.node; + var predecessors = predecessorsOf(node); + + if (predecessors.isNotEmpty) { + var positions = predecessors.map((p) => nodeData[p]?.position ?? 0).toList(); + positions.sort(); + element.measure = medianValue(positions).toDouble(); + } else { + element.measure = element.pos.toDouble(); + } + } else if (element is ContainerElement) { + element.measure = element.pos.toDouble(); + } + } + } + + void assignPositions(List layer) { + var currentPos = 0; + for (var element in layer) { + element.pos = currentPos; + + if (element is NodeElement) { + nodeData[element.node]?.position = currentPos; + currentPos++; + } else if (element is ContainerElement) { + currentPos += element.container.size(); + } + } + } + + // Eiglsperger Step 3: Initial ordering based on measures + void stepThree(List layer) { + var vertices = []; + var containers = []; + + // Separate vertices and containers + for (var element in layer) { + if (element is ContainerElement && element.container.size() > 0) { + containers.add(element); + } else if (element is NodeElement) { + var data = nodeData[element.node]; + if (!(data?.isSegmentVertex ?? false)) { + vertices.add(element); + } + } + } + + // Sort by measure + vertices.sort((a, b) => a.measure.compareTo(b.measure)); + containers.sort((a, b) => a.measure.compareTo(b.measure)); + + // Merge lists according to Eiglsperger algorithm + var merged = mergeSortedLists(vertices, containers); + + layer.clear(); + layer.addAll(merged); + } + + List mergeSortedLists(List vertices, List containers) { + var result = []; + var vIndex = 0; + var cIndex = 0; + + while (vIndex < vertices.length && cIndex < containers.length) { + var vertex = vertices[vIndex]; + var container = containers[cIndex]; + + if (vertex.measure <= container.pos) { + result.add(vertex); + vIndex++; + } else if (vertex.measure >= (container.pos + container.container.size() - 1)) { + result.add(container); + cIndex++; + } else { + // Split container + var k = (vertex.measure - container.pos).ceil(); + var split = ContainerX.splitAt(container.container, k); + + if (split.left.size() > 0) { + result.add(ContainerElement(split.left)); + } + result.add(vertex); + if (split.right.size() > 0) { + split.right.pos = container.pos + k; + containers.insert(cIndex + 1, ContainerElement(split.right)); + } + vIndex++; + cIndex++; + } + } + + // Add remaining elements + while (vIndex < vertices.length) { + result.add(vertices[vIndex++]); + } + while (cIndex < containers.length) { + result.add(containers[cIndex++]); + } + + return result; + } + + // Eiglsperger Step 4: Place Q-vertices according to their segments + void stepFour(List layer, int layerIndex) { + var segmentVertices = []; + + // Find segment vertices in this layer + for (var element in List.from(layer)) { + if (element is NodeElement) { + var data = nodeData[element.node]; + if (data?.isSegmentVertex ?? false) { + segmentVertices.add(element); + layer.remove(element); + } + } + } + + // Place each segment vertex + for (var segmentElement in segmentVertices) { + var segmentNode = segmentElement.node; + var data = nodeData[segmentNode]; + var segment = data?.segment; + + if (segment != null) { + // Find container containing this segment + ContainerElement? containerElement; + for (var element in layer) { + if (element is ContainerElement && element.container.contains(segment)) { + containerElement = element; + break; + } + } + + if (containerElement != null) { + var containerIndex = layer.indexOf(containerElement); + var split = ContainerX.split(containerElement.container, segment); + + layer.removeAt(containerIndex); + + if (split.left.size() > 0) { + layer.insert(containerIndex, ContainerElement(split.left)); + containerIndex++; + } + + layer.insert(containerIndex, segmentElement); + containerIndex++; + + if (split.right.size() > 0) { + layer.insert(containerIndex, ContainerElement(split.right)); + } + } else { + // No container found, just add the segment vertex + layer.add(segmentElement); + } + } + } + + updateIndices(layer); + } + + void updateIndices(List layer) { + for (var i = 0; i < layer.length; i++) { + layer[i].index = i; + if (layer[i] is NodeElement) { + var node = (layer[i] as NodeElement).node; + nodeData[node]?.position = i; + } + } + } + + // Eiglsperger Step 5: Cross counting with virtual edges + double stepFive(List currentLayer, List nextLayer, + int currentRank, int nextRank) { + // Remove empty containers + currentLayer.removeWhere((e) => e is ContainerElement && e.container.isEmpty); + nextLayer.removeWhere((e) => e is ContainerElement && e.container.isEmpty); + + updateIndices(currentLayer); + updateIndices(nextLayer); + + // Collect all edges including virtual edges + var allEdges = []; + + // Add regular graph edges between these layers + for (var edge in graph.edges) { + if (nodeData[edge.source]?.layer == currentRank && + nodeData[edge.destination]?.layer == nextRank) { + allEdges.add(edge); + } + } + + // Add virtual edges for containers + for (var element in nextLayer) { + if (element is ContainerElement && element.container.size() > 0) { + var virtualEdge = VirtualEdge('virtual', element, element.container.size()); + allEdges.add(virtualEdge); + } else if (element is NodeElement) { + var data = nodeData[element.node]; + if (data?.isSegmentVertex ?? false) { + var virtualEdge = VirtualEdge('virtual', element.node, 1); + allEdges.add(virtualEdge); + } + } + } + + // Count crossings with weights + return countWeightedCrossings(allEdges, nextLayer); + } + + double countWeightedCrossings(List edges, List nextLayer) { + var crossings = 0.0; + + for (var i = 0; i < edges.length - 1; i++) { + for (var j = i + 1; j < edges.length; j++) { + var edge1 = edges[i]; + var edge2 = edges[j]; + + var weight1 = getEdgeWeight(edge1); + var weight2 = getEdgeWeight(edge2); + + var pos1 = getTargetPosition(edge1, nextLayer); + var pos2 = getTargetPosition(edge2, nextLayer); + + if (pos1 > pos2) { + crossings += weight1 * weight2; + } + } + } + + return crossings; + } + + int getEdgeWeight(dynamic edge) { + if (edge is VirtualEdge) { + return edge.weight; + } + return 1; + } + + int getTargetPosition(dynamic edge, List nextLayer) { + if (edge is VirtualEdge) { + for (var i = 0; i < nextLayer.length; i++) { + if ((nextLayer[i] is ContainerElement && nextLayer[i] == edge.target) || + (nextLayer[i] is NodeElement && (nextLayer[i] as NodeElement).node == edge.target)) { + return i; + } + } + } else if (edge is Edge) { + for (var i = 0; i < nextLayer.length; i++) { + if (nextLayer[i] is NodeElement && + (nextLayer[i] as NodeElement).node == edge.destination) { + return i; + } + } + } + return 0; + } + + // Eiglsperger Step 6: Scan and ensure alternating structure + void stepSix(List layer) { + var scanned = []; + + for (var i = 0; i < layer.length; i++) { + var element = layer[i]; + + if (scanned.isEmpty) { + if (element is ContainerElement) { + scanned.add(element); + } else { + scanned.add(ContainerElement(ContainerX.createEmpty())); + scanned.add(element); + } + } else { + var previous = scanned.last; + + if (previous is ContainerElement && element is ContainerElement) { + // Join containers + previous.container.join(element.container); + } else if (previous is NodeElement && element is NodeElement) { + // Insert empty container between nodes + scanned.add(ContainerElement(ContainerX.createEmpty())); + scanned.add(element); + } else { + scanned.add(element); + } + } + } + + // Ensure ends with container + if (scanned.isNotEmpty && scanned.last is NodeElement) { + scanned.add(ContainerElement(ContainerX.createEmpty())); + } + + layer.clear(); + layer.addAll(scanned); + updateIndices(layer); + } + + void updateNodePositions() { + for (var layerIndex = 0; layerIndex < layers.length; layerIndex++) { + for (var nodeIndex = 0; nodeIndex < layers[layerIndex].length; nodeIndex++) { + var node = layers[layerIndex][nodeIndex]; + nodeData[node]?.position = nodeIndex; + + var data = nodeData[node]; + if (data != null) { + data.rank = layerIndex; + } + } + } + } + + void coordinateAssignment() { + assignX(); + assignY(); + var offset = getOffset(graph, needReverseOrder()); + + graph.nodes.forEach((v) { + v.position = getPosition(v, offset); + }); + } + + void assignX() { + // Simplified coordinate assignment - can be enhanced with full Brandes-Köpf algorithm + var separation = configuration.nodeSeparation; + var vertical = isVertical(); + + for (var layerIndex = 0; layerIndex < layers.length; layerIndex++) { + var layer = layers[layerIndex]; + var x = 0.0; + + for (var nodeIndex = 0; nodeIndex < layer.length; nodeIndex++) { + var node = layer[nodeIndex]; + var width = vertical ? node.width + separation : node.height; + node.x = x + width / 2; + x += width + separation; + } + } + } + + void assignXx() { + // Existing implementation remains the same + final root = >[]; + // each node points to its aligned neighbor in the layer below.; + final align = >[]; + final sink = >[]; + final x = >[]; + // minimal separation between the roots of different classes.; + final shift = >[]; + // the width of each block (max width of node in block); + final blockWidth = >[]; + + for (var i = 0; i < 4; i++) { + root.add({}); + align.add({}); + sink.add({}); + shift.add({}); + x.add({}); + blockWidth.add({}); + + graph.nodes.forEach((n) { + root[i][n] = n; + align[i][n] = n; + sink[i][n] = n; + shift[i][n] = double.infinity; + x[i][n] = double.negativeInfinity; + blockWidth[i][n] = 0; + }); + } + var separation = configuration.nodeSeparation; + + var vertical = isVertical(); + for (var downward = 0; downward <= 1; downward++) { + var isDownward = downward == 0; + final type1Conflicts = {}; + for (var leftToRight = 0; leftToRight <= 1; leftToRight++) { + final k = 2 * downward + leftToRight; + var isLeftToRight = leftToRight == 0; + verticalAlignment( + root[k], align[k], type1Conflicts, isDownward, isLeftToRight); + graph.nodes.forEach((v) { + final r = root[k][v]!; + blockWidth[k][r] = max( + blockWidth[k][r]!, vertical ? v.width + separation : v.height); + }); + horizontalCompactation(align[k], root[k], sink[k], shift[k], blockWidth[k], x[k], isLeftToRight, isDownward, layers, separation); + } + } + + balance(x, blockWidth); + } + + void balance(List> x, List> blockWidth) { + final coordinates = {}; + + // switch (configuration.coordinateAssignment) { + // case CoordinateAssignment.Average: + // var minWidth = double.infinity; + // + // var smallestWidthLayout = 0; + // final minArray = List.filled(4, 0.0); + // final maxArray = List.filled(4, 0.0); + // + // // Get the layout with the smallest width and set minimum and maximum value for each direction; + // for (var i = 0; i < 4; i++) { + // minArray[i] = double.infinity; + // maxArray[i] = 0; + // + // graph.nodes.forEach((v) { + // final bw = 0.5 * blockWidth[i][v]!; + // var xp = x[i][v]! - bw; + // if (xp < minArray[i]) { + // minArray[i] = xp; + // } + // xp = x[i][v]! + bw; + // if (xp > maxArray[i]) { + // maxArray[i] = xp; + // } + // }); + // + // final width = maxArray[i] - minArray[i]; + // if (width < minWidth) { + // minWidth = width; + // smallestWidthLayout = i; + // } + // } + // + // // Align the layouts to the one with the smallest width + // for (var layout = 0; layout < 4; layout++) { + // if (layout != smallestWidthLayout) { + // // Align the left to right layouts to the left border of the smallest layout + // var diff = 0.0; + // if (layout < 2) { + // diff = minArray[layout] - minArray[smallestWidthLayout]; + // } else { + // // Align the right to left layouts to the right border of the smallest layout + // diff = maxArray[layout] - maxArray[smallestWidthLayout]; + // } + // if (diff > 0) { + // x[layout].keys.forEach((n) { + // x[layout][n] = x[layout][n]! - diff; + // }); + // } else { + // x[layout].keys.forEach((n) { + // x[layout][n] = x[layout][n]! + diff; + // }); + // } + // } + // } + // + // // Get the average median of each coordinate + // var values = List.filled(4, 0.0); + // graph.nodes.forEach((n) { + // for (var i = 0; i < 4; i++) { + // values[i] = x[i][n]!; + // } + // values.sort(); + // var average = (values[1] + values[2]) * 0.5; + // coordinates[n] = average; + // }); + // break; + // case CoordinateAssignment.DownRight: + // graph.nodes.forEach((n) { + // coordinates[n] = x[0][n] ?? 0.0; + // }); + // break; + // case CoordinateAssignment.DownLeft: + // graph.nodes.forEach((n) { + // coordinates[n] = x[1][n] ?? 0.0; + // }); + // break; + // case CoordinateAssignment.UpRight: + // graph.nodes.forEach((n) { + // coordinates[n] = x[2][n] ?? 0.0; + // }); + // break; + // case CoordinateAssignment.UpLeft: + // graph.nodes.forEach((n) { + // coordinates[n] = x[3][n] ?? 0.0; + // }); + // break; + // } + + graph.nodes.forEach((n) { + coordinates[n] = x[3][n] ?? 0.0; + }); + // Get the minimum coordinate value + var minValue = coordinates.values.reduce(min); + + // Set left border to 0 + if (minValue != 0) { + coordinates.keys.forEach((n) { + coordinates[n] = coordinates[n]! - minValue; + }); + } + + // resolveOverlaps(coordinates); + + + graph.nodes.forEach((v) { + v.x = coordinates[v]!; + }); + } + + void resolveOverlaps(Map coordinates) { + for (var layer in layers) { + var layerNodes = List.from(layer); + layerNodes.sort( + (a, b) => nodeData[a]!.position.compareTo(nodeData[b]!.position)); + + var data = nodeData[layerNodes.first]; + if (data?.layer != 0) { + var leftCoordinate = 0.0; + for (var i = 1; i < layerNodes.length; i++) { + var currentNode = layerNodes[i]; + if (!nodeData[currentNode]!.isDummy) { + var previousNode = getPreviousNonDummyNode(layerNodes, i); + + if (previousNode != null) { + leftCoordinate = coordinates[previousNode]! + + previousNode.width + + configuration.nodeSeparation; + } else { + leftCoordinate = 0.0; + } + + if (leftCoordinate > coordinates[currentNode]!) { + var adjustment = leftCoordinate - coordinates[currentNode]!; + if (coordinates[currentNode] != null) { + coordinates[currentNode] = + coordinates[currentNode]! + adjustment; + } + } + } + } + } + } + } + + Node? getPreviousNonDummyNode(List layerNodes, int currentIndex) { + for (var i = currentIndex - 1; i >= 0; i--) { + var previousNode = layerNodes[i]; + if (!nodeData[previousNode]!.isDummy) { + return previousNode; + } + } + return null; + } + + // Map markType1Conflicts(bool downward) { + // if (layers.length >= 4) { + // int upper; + // int lower; // iteration bounds; + // int k1; // node position boundaries of closest inner segments; + // if (downward) { + // lower = 1; + // upper = layers.length - 2; + // } else { + // lower = layers.length - 1; + // upper = 2; + // } + // /*; + // * iterate level[2..h-2] in the given direction; + // * available 1 levels to h; + // */ + // for (var i = lower; + // downward ? i <= upper : i >= upper; + // i += downward ? 1 : -1) { + // var k0 = 0; + // var firstIndex = 0; // index of first node on layer; + // final currentLevel = layers[i]; + // final nextLevel = downward ? layers[i + 1] : layers[i - 1]; + // + // // for all nodes on next level; + // for (var l1 = 0; l1 < nextLevel.length; l1++) { + // final virtualTwin = virtualTwinNode(nextLevel[l1], downward); + // + // if (l1 == nextLevel.length - 1 || virtualTwin != null) { + // k1 = currentLevel.length - 1; + // + // if (virtualTwin != null) { + // k1 = positionOfNode(virtualTwin); + // } + // + // while (firstIndex <= l1) { + // final upperNeighbours = getAdjNodes(nextLevel[l1], downward); + // + // for (var currentNeighbour in upperNeighbours) { + // /*; + // * XXX< 0 in first iteration is still ok for indizes starting; + // * with 0 because no index can be smaller than 0; + // */ + // final currentNeighbourIndex = positionOfNode(currentNeighbour); + // + // if (currentNeighbourIndex < k0 || currentNeighbourIndex > k1) { + // type1Conflicts[l1] = currentNeighbourIndex; + // } + // } + // firstIndex++; + // } + // + // k0 = k1; + // } + // } + // } + // } + // return type1Conflicts; + // } + + void verticalAlignment(Map root, Map align, + Map type1Conflicts, bool downward, bool leftToRight) { + // for all Level; + + var layersa = downward ? layers : layers.reversed; + + for (var layer in layersa) { + // As with layers, we need a reversed iterator for blocks for different directions + var nodes = leftToRight ? layer : layer.reversed; + // Do an initial placement for all blocks + var r = leftToRight ? -1 : double.infinity; + for (var v in nodes) { + final adjNodes = getAdjNodes(v, downward); + if (adjNodes.isNotEmpty) { + var midLevelValue = adjNodes.length / 2; + // Calculate medians + final medians = adjNodes.length % 2 == 1 + ? [adjNodes[midLevelValue.floor()]] + : [ + adjNodes[midLevelValue.toInt() - 1], + adjNodes[midLevelValue.toInt()] + ]; + + // For all median neighbours in direction of H + for (var m in medians) { + final posM = positionOfNode(m); + // if segment (u,v) not marked by type1 conflicts AND ...; + if (align[v] == v && + type1Conflicts[positionOfNode(v)] != posM && + (leftToRight ? r < posM : r > posM)) { + align[m] = v; + root[v] = root[m]; + align[v] = root[v]; + r = posM; + } + } + } + } + } + } + + void horizontalCompactation( + Map align, + Map root, + Map sink, + Map shift, + Map blockWidth, + Map x, + bool leftToRight, + bool downward, + List> layers, + int separation) { + // calculate class relative coordinates for all roots; + // If the layers are traversed from right to left, a reverse iterator is needed (note that this does not change the original list of layers) + var layersa = leftToRight ? layers : layers.reversed; + + for (var layer in layersa) { + // As with layers, we need a reversed iterator for blocks for different directions + var nodes = downward ? layer : layer.reversed; + // Do an initial placement for all blocks + for (var v in nodes) { + if (root[v] == v) { + placeBlock(v, sink, shift, x, align, blockWidth, root, leftToRight, + layers, separation); + } + } + } + + var d = 0; + var i = downward ? 0 : layers.length - 1; + while (downward && i <= layers.length - 1 || !downward && i >= 0) { + final currentLevel = layers[i]; + final v = currentLevel[leftToRight ? 0 : currentLevel.length - 1]; + if (v == sink[root[v]]) { + final oldShift = shift[v]!; + if (oldShift < double.infinity) { + shift[v] = oldShift + d; + d += oldShift.toInt(); + } else { + shift[v] = 0; + } + } + i = downward ? i + 1 : i - 1; + } + + // apply root coordinates for all aligned nodes; + // (place block did this only for the roots)+; + graph.nodes.forEach((v) { + x[v] = x[root[v]]!; + final shiftVal = shift[sink[root[v]]]!; + if (shiftVal < double.infinity) { + x[v] = x[v]! + shiftVal; // apply shift for each class; + } + }); + } + + void placeBlock( + Node v, + Map sink, + Map shift, + Map x, + Map align, + Map blockWidth, + Map root, + bool leftToRight, + List> layers, + int separation) { + if (x[v] == double.negativeInfinity) { + x[v] = 0; + var currentNode = v; + + try { + do { + // if not first node on layer; + final hasPredecessor = + leftToRight && positionOfNode(currentNode) > 0 || + !leftToRight && + positionOfNode(currentNode) < + layers[getLayerIndex(currentNode)].length - 1; + // print("Pred $hasPredecessor ${getLayerIndex(currentNode)>0} ${positionOfNode(currentNode)>0}"); + if (hasPredecessor) { + final pred = predecessor(currentNode, leftToRight); + /* Get the root of u (proceeding all the way upwards in the block) */ + final u = root[pred]!; + /* Place the block of u recursively */ + placeBlock(u, sink, shift, x, align, blockWidth, root, leftToRight, + layers, separation); + /* If v is its own sink yet, set its sink to the sink of u */ + if (sink[v] == v) { + sink[v] = sink[u]!; + } + /* If v and u have different sinks (i.e. they are in different classes), + * shift the sink of u so that the two blocks are separated by the preferred gap */ + var gap = separation + 0.5 * (blockWidth[u]! + blockWidth[v]!); + if (sink[v] != sink[u]) { + if (leftToRight) { + shift[sink[u]!] = min(shift[sink[u]]!, x[v]! - x[u]! - gap); + } else { + shift[sink[u]!] = max(shift[sink[u]]!, x[v]! - x[u]! + gap); + } + } else { + /* v and u have the same sink, i.e. they are in the same level. + Make sure that v is separated from u by at least gap.*/ + if (leftToRight) { + x[v] = max(x[v]!, x[u]! + gap); + } else { + x[v] = min(x[v]!, x[u]! - gap); + } + } + } + currentNode = align[currentNode]!; + } while (currentNode != v); + } catch (e) { + print(e); + } + } + } + + List successorsOf(Node? node) { + return graph.successorsOf(node); + } + + List predecessorsOf(Node? node) { + return graph.predecessorsOf(node); + } + + List getAdjNodes(Node node, bool downward) { + if (downward) { + return predecessorsOf(node); + } else { + return successorsOf(node); + } + } + + // predecessor; + Node? predecessor(Node? v, bool leftToRight) { + final pos = positionOfNode(v); + final rank = getLayerIndex(v); + final level = layers[rank]; + if (leftToRight && pos != 0 || !leftToRight && pos != level.length - 1) { + return level[(leftToRight) ? pos - 1 : pos + 1]; + } else { + return null; + } + } + + Node? virtualTwinNode(Node node, bool downward) { + if (!isLongEdgeDummy(node)) { + return null; + } + final adjNodes = getAdjNodes(node, downward); + return adjNodes.isEmpty ? null : adjNodes[0]; + } + + // get node index in layer; + int positionOfNode(Node? node) { + return nodeData[node]?.position ?? -1; + } + + int getLayerIndex(Node? node) { + return nodeData[node]?.layer ?? -1; + } + + bool isLongEdgeDummy(Node? v) { + final successors = successorsOf(v); + return nodeData[v!]!.isDummy && + successors.length == 1 && + nodeData[successors[0]]!.isDummy; + } + + void assignY() { + var k = layers.length; + var yPos = 0.0; + var vertical = isVertical(); + + for (var i = 0; i < k; i++) { + var level = layers[i]; + var maxHeight = 0.0; + + level.forEach((node) { + var h = nodeData[node]!.isDummy + ? 0.0 + : vertical + ? node.height + : node.width; + if (h > maxHeight) { + maxHeight = h; + } + node.y = yPos; + }); + + if (i < k - 1) { + yPos += configuration.levelSeparation + maxHeight; + } + } + } + + void denormalize() { + // Remove dummy vertices and create bend points for articulated edges + for (var i = 1; i < layers.length - 1; i++) { + final iterator = layers[i].iterator; + + while (iterator.moveNext()) { + final current = iterator.current; + if (nodeData[current]!.isDummy) { + final predecessor = graph.predecessorsOf(current)[0]; + final successor = graph.successorsOf(current)[0]; + final bendPoints = _edgeData[graph.getEdgeBetween(predecessor, current)!]!.bendPoints; + + if (bendPoints.isEmpty || !bendPoints.contains(current.x + predecessor.width / 2)) { + bendPoints.add(predecessor.x + predecessor.width / 2); + bendPoints.add(predecessor.y + predecessor.height / 2); + bendPoints.add(current.x + predecessor.width / 2); + bendPoints.add(current.y); + } + + if (!nodeData[predecessor]!.isDummy) { + bendPoints.add(current.x + predecessor.width / 2); + } else { + bendPoints.add(current.x); + } + bendPoints.add(current.y); + + if (nodeData[successor]!.isDummy) { + bendPoints.add(successor.x + predecessor.width / 2); + } else { + bendPoints.add(successor.x + successor.width / 2); + } + bendPoints.add(successor.y + successor.height / 2); + + graph.removeEdgeFromPredecessor(predecessor, current); + graph.removeEdgeFromPredecessor(current, successor); + + final edge = graph.addEdge(predecessor, successor); + final edgeData = EiglspergerEdgeData(); + edgeData.bendPoints = bendPoints; + this._edgeData[edge] = edgeData; + + graph.removeNode(current); + } + } + } + } + + void restoreCycle() { + graph.nodes.forEach((n) { + if (nodeData[n]!.isReversed) { + nodeData[n]!.reversed.forEach((target) { + final bendPoints = _edgeData[graph.getEdgeBetween(target, n)!]!.bendPoints; + graph.removeEdgeFromPredecessor(target, n); + final edge = graph.addEdge(n, target); + + final edgeData = EiglspergerEdgeData(); + edgeData.bendPoints = bendPoints; + _edgeData[edge] = edgeData; + }); + } + }); + } + + Offset getOffset(Graph graph, bool needReverseOrder) { + var offsetX = double.infinity; + var offsetY = double.infinity; + + if (needReverseOrder) { + offsetY = double.minPositive; + } + + graph.nodes.forEach((node) { + if (needReverseOrder) { + offsetX = min(offsetX, node.x); + offsetY = max(offsetY, node.y); + } else { + offsetX = min(offsetX, node.x); + offsetY = min(offsetY, node.y); + } + }); + + return Offset(offsetX, offsetY); + } + + Offset getPosition(Node node, Offset offset) { + Offset finalOffset; + switch (configuration.orientation) { + case 1: + finalOffset = Offset(node.x - offset.dx, node.y); + break; + case 2: + finalOffset = Offset(node.x - offset.dx, offset.dy - node.y); + break; + case 3: + finalOffset = Offset(node.y, node.x - offset.dx); + break; + case 4: + finalOffset = Offset(offset.dy - node.y, node.x - offset.dx); + break; + default: + finalOffset = Offset(0, 0); + break; + } + + return finalOffset; + } + + static double medianValue(List positions) { + if (positions.isEmpty) return 0.0; + if (positions.length == 1) return positions[0].toDouble(); + + positions.sort(); + final mid = positions.length ~/ 2; + + if (positions.length % 2 == 1) { + return positions[mid].toDouble(); + } else if (positions.length == 2) { + return (positions[0] + positions[1]) / 2.0; + } else { + final left = positions[mid - 1] - positions[0]; + final right = positions[positions.length - 1] - positions[mid]; + if (left + right == 0) return 0.0; + return (positions[mid - 1] * right + positions[mid] * left) / (left + right); + } + } + + @override + void init(Graph? graph) { + this.graph = copyGraph(graph!); + reset(); + initNodeData(); + cycleRemoval(); + layerAssignment(); + nodeOrdering(); + coordinateAssignment(); + denormalize(); + restoreCycle(); + } + + @override + void setDimensions(double width, double height) { + // Can be used to set layout bounds if needed + } +} \ No newline at end of file diff --git a/lib/layered/SugiyamaAlgorithm.dart b/lib/layered/SugiyamaAlgorithm.dart index 0607f35..6dc6241 100644 --- a/lib/layered/SugiyamaAlgorithm.dart +++ b/lib/layered/SugiyamaAlgorithm.dart @@ -16,7 +16,8 @@ class SugiyamaAlgorithm extends Algorithm { var nodeCount = 1; SugiyamaAlgorithm(this.configuration) { - renderer = SugiyamaEdgeRenderer(nodeData, edgeData, configuration.bendPointShape, configuration.addTriangleToEdge); + renderer = SugiyamaEdgeRenderer(nodeData, edgeData, + configuration.bendPointShape, configuration.addTriangleToEdge); } int get dummyId => 'Dummy ${nodeCount++}'.hashCode; @@ -42,29 +43,18 @@ class SugiyamaAlgorithm extends Algorithm { layerAssignment(); nodeOrdering(); //expensive operation coordinateAssignment(); //expensive operation + // if (configuration.enableAngleOptimization) { + // final optimizer = CrossingAngleOptimizer(this.graph, layers, nodeData, edgeData, configuration); + // optimizer.optimize(); + // // The optimizer modifies the Y coordinates in place, so no need to call assignY() again. + // } shiftCoordinates(shiftX, shiftY); - final graphSize = calculateGraphSize(this.graph); + final graphSize = graph.calculateGraphSize(); denormalize(); restoreCycle(); return graphSize; } - Size calculateGraphSize(Graph graph) { - var left = double.infinity; - var top = double.infinity; - var right = double.negativeInfinity; - var bottom = double.negativeInfinity; - - graph.nodes.forEach((node) { - left = min(left, node.x); - top = min(top, node.y); - right = max(right, node.x + node.width); - bottom = max(bottom, node.y + node.height); - }); - - return Size(right - left, bottom - top); - } - void shiftCoordinates(double shiftX, double shiftY) { layers.forEach((List arrayList) { arrayList.forEach((it) { @@ -91,12 +81,7 @@ class SugiyamaAlgorithm extends Algorithm { graph.edges.forEach((edge) { edgeData[edge] = SugiyamaEdgeData(); }); - } - void cycleRemoval() { - graph.nodes.forEach((node) { - dfs(node); - }); } void dfs(Node node) { @@ -118,12 +103,29 @@ class SugiyamaAlgorithm extends Algorithm { stack.remove(node); } - // top sort + add dummy nodes; void layerAssignment() { - if (graph.nodes.isEmpty) { - return; + switch (configuration.layeringStrategy) { + case LayeringStrategy.topDown: + layerAssignmentTopDown(); + break; + case LayeringStrategy.longestPath: + layerAssignmentLongestPath(); + break; + case LayeringStrategy.coffmanGraham: + layerAssignmentCoffmanGraham(); + break; + case LayeringStrategy.networkSimplex: + layerAssignmentNetworkSimplex(); + break; } - // build layers; + + // Add dummy nodes for long edges + addDummyNodes(); + } + + void layerAssignmentTopDown() { + if (graph.nodes.isEmpty) return; + final copiedGraph = copyGraph(graph); var roots = getRootNodes(copiedGraph); @@ -133,7 +135,159 @@ class SugiyamaAlgorithm extends Algorithm { roots = getRootNodes(copiedGraph); } - // add dummy's; + // Set layer metadata + for (var i = 0; i < layers.length; i++) { + for (var j = 0; j < layers[i].length; j++) { + nodeData[layers[i][j]]!.layer = i; + nodeData[layers[i][j]]!.position = j; + } + } + } + + void layerAssignmentLongestPath() { + if (graph.nodes.isEmpty) return; + + var U = {}; + var Z = {}; + var V = Set.from(graph.nodes); + var currentLayer = 0; + layers = [[]]; + + while (U.length != graph.nodes.length) { + var candidates = V + .where((v) => !U.contains(v) && Z.containsAll(graph.successorsOf(v))); + + if (candidates.isNotEmpty) { + var node = candidates.first; + layers[currentLayer].add(node); + U.add(node); + } else { + currentLayer++; + layers.add([]); + Z.addAll(U); + } + } + + // Reverse layers and set metadata + layers = layers.reversed.where((layer) => layer.isNotEmpty).toList(); + for (var i = 0; i < layers.length; i++) { + for (var j = 0; j < layers[i].length; j++) { + nodeData[layers[i][j]]!.layer = i; + nodeData[layers[i][j]]!.position = j; + } + } + } + + void layerAssignmentCoffmanGraham() { + if (graph.nodes.isEmpty) return; + + var width = (graph.nodes.length / 10).ceil(); + + var Z = {}; + var lambda = {}; + var V = Set.from(graph.nodes); + + // Assign lambda values based on in-degree + V.forEach((v) => lambda[v] = double.maxFinite.toInt()); + for (var i = 0; i < V.length; i++) { + var mv = V.where((v) => lambda[v] == double.maxFinite.toInt()).reduce( + (a, b) => + graph.getInEdges(a).length <= graph.getInEdges(b).length ? a : b); + lambda[mv] = i; + } + + var k = 0; + layers = [[]]; + var U = {}; + + while (U.length != graph.nodes.length) { + var candidates = V + .where((v) => !U.contains(v) && U.containsAll(graph.successorsOf(v))); + + if (candidates.isNotEmpty) { + var got = candidates.reduce((a, b) => lambda[a]! > lambda[b]! ? a : b); + + if (layers[k].length < width && + Z.containsAll(graph.successorsOf(got))) { + layers[k].add(got); + } else { + Z.addAll(layers[k]); + k++; + layers.add([]); + layers[k].add(got); + } + U.add(got); + } + } + + // Remove empty layers and reverse + layers = layers.where((l) => l.isNotEmpty).toList().reversed.toList(); + + // Set metadata + for (var i = 0; i < layers.length; i++) { + for (var j = 0; j < layers[i].length; j++) { + nodeData[layers[i][j]]!.layer = i; + nodeData[layers[i][j]]!.position = j; + } + } + } + + void layerAssignmentNetworkSimplex() { + // Start with longest path as base + layerAssignmentLongestPath(); + + // Simple optimization: try to minimize edge span + var improved = true; + var iterations = 5; + + while (improved && iterations > 0) { + improved = false; + iterations--; + + for (var i = layers.length - 1; i >= 0; i--) { + var layer = List.from(layers[i]); + var nodesToMove = {}; + + for (var v in layer) { + if (graph.getOutEdges(v).isEmpty) continue; + + var outgoingEdges = graph.getOutEdges(v); + if (outgoingEdges.isNotEmpty) { + var minRank = outgoingEdges + .map((e) => nodeData[e.destination]!.layer - 1) + .reduce(min); + + if (minRank != nodeData[v]!.layer && minRank >= 0) { + nodesToMove[v] = minRank; + improved = true; + } + } + } + + // Move nodes + for (var entry in nodesToMove.entries) { + var node = entry.key; + var newRank = entry.value; + var oldRank = nodeData[node]!.layer; + + layers[oldRank].remove(node); + if (newRank < layers.length) { + layers[newRank].add(node); + nodeData[node]!.layer = newRank; + } + } + } + + // Recompute positions + for (var i = 0; i < layers.length; i++) { + for (var j = 0; j < layers[i].length; j++) { + nodeData[layers[i][j]]!.position = j; + } + } + } + } + + void addDummyNodes() { for (var i = 0; i < layers.length - 1; i++) { var indexNextLayer = i + 1; var currentLayer = layers[i]; @@ -142,7 +296,11 @@ class SugiyamaAlgorithm extends Algorithm { for (var node in currentLayer) { final edges = graph.edges .where((element) => - element.source == node && (nodeData[element.destination]!.layer - nodeData[node]!.layer).abs() > 1).toList(); + element.source == node && + (nodeData[element.destination]!.layer - nodeData[node]!.layer) + .abs() > + 1) + .toList(); final iterator = edges.iterator; @@ -154,7 +312,8 @@ class SugiyamaAlgorithm extends Algorithm { dummyNodeData.layer = indexNextLayer; nextLayer.add(dummy); nodeData[dummy] = dummyNodeData; - dummy.size = Size(edge.source.width, 0); // calc TODO avg layer height; + dummy.size = + Size(edge.source.width, 0); // calc TODO avg layer height; final dummyEdge1 = graph.addEdge(edge.source, dummy); final dummyEdge2 = graph.addEdge(dummy, edge.destination); edgeData[dummyEdge1] = SugiyamaEdgeData(); @@ -188,37 +347,36 @@ class SugiyamaAlgorithm extends Algorithm { } void nodeOrdering() { - final best = >[...layers]; + // The `layers` variable is the member variable of the class. + // We will modify it directly. There is no need for a separate 'best' copy + // with the current iterative improvement strategy. - // Precalculate predecessor and successor info that we require during the following processes. + // Precalculate predecessor and successor info, must be done here after adding the dummy nodes graph.edges.forEach((element) { nodeData[element.source]?.successorNodes.add(element.destination); nodeData[element.destination]?.predecessorNodes.add(element.source); }); for (var i = 0; i < configuration.iterations; i++) { - median(best, i); - // transpose(best); - // if (!changed) { - // break; - // } - // var c = crossing(best); - // var l = crossing(layers); - // if (c < l) { - // layers = best; - // } - var changed = transpose(best); + // Apply the median heuristic to reorder nodes in each layer. + median(layers, i); + + // Apply the transpose heuristic to fine-tune the ordering by swapping adjacent nodes. + // This will use the efficient AccumulatorTree-based approach we defined. + var changed = configuration.crossMinimizationStrategy == + CrossMinimizationStrategy.simple + ? transposeSimple(layers) + : transposeAccumulator(layers); + // If a full pass of transpose made no improvements, we've stabilized. if (!changed) { break; } } - // Set the final position of the nodes in memory - var pos = 0; + + // Set final positions based on the optimized order. for (var currentLayer in layers) { - pos = 0; - for (var node in currentLayer) { - nodeData[node]?.position = pos; - pos++; + for (var pos = 0; pos < currentLayer.length; pos++) { + nodeData[currentLayer[pos]]?.position = pos; } } } @@ -254,7 +412,9 @@ class SugiyamaAlgorithm extends Algorithm { final left = positions[median - 1] - positions[0]; final right = positions[positions.length - 1] - positions[median]; if (left + right != 0) { - median = (positions[median - 1] * right + positions[median] * left) ~/ (left + right); + median = + (positions[median - 1] * right + positions[median] * left) ~/ + (left + right); } } @@ -263,7 +423,8 @@ class SugiyamaAlgorithm extends Algorithm { } } - currentLayer.sort((n1, n2) => nodeData[n1!]!.median - nodeData[n2!]!.median); + currentLayer + .sort((n1, n2) => nodeData[n1!]!.median - nodeData[n2!]!.median); } } else { for (var l = 1; l < layers.length; l++) { @@ -286,7 +447,9 @@ class SugiyamaAlgorithm extends Algorithm { if (positions.length == 1) { median = positions[0]; } else { - median = (positions[(positions.length / 2.0).ceil()] + positions[(positions.length / 2.0).ceil() - 1]) ~/ 2; + median = (positions[(positions.length / 2.0).ceil()] + + positions[(positions.length / 2.0).ceil() - 1]) ~/ + 2; } for (var i = currentLayer.length - 1; i > 1; i--) { @@ -295,12 +458,13 @@ class SugiyamaAlgorithm extends Algorithm { } } - currentLayer.sort((n1, n2) => nodeData[n1!]!.median - nodeData[n2!]!.median); + currentLayer + .sort((n1, n2) => nodeData[n1!]!.median - nodeData[n2!]!.median); } } } - bool transpose(List> layers) { + bool transposeSimple(List> layers) { var changed = false; var improved = true; @@ -311,7 +475,8 @@ class SugiyamaAlgorithm extends Algorithm { final southernNodes = layers[l + 1]; // Create a map that holds the index of every [Node]. Key is the [Node] and value is the index of the item. - final indexMap = HashMap.of(northernNodes.asMap().map((key, value) => MapEntry(value, key))); + final indexMap = HashMap.of( + northernNodes.asMap().map((key, value) => MapEntry(value, key))); for (var i = 0; i < southernNodes.length - 1; i++) { final v = southernNodes[i]; @@ -327,6 +492,84 @@ class SugiyamaAlgorithm extends Algorithm { return changed; } + bool transposeAccumulator(List> layers) { + var changed = false; + var improved = true; + + while (improved) { + improved = false; + for (var l = 0; l < layers.length - 1; l++) { + final upperLayer = layers[l]; + final lowerLayer = layers[l + 1]; + + // Calculate the total crossings for this pair of layers before any swaps. + var crossingsBefore = _getBiLayerCrossings(upperLayer, lowerLayer); + if (crossingsBefore == 0) continue; + + for (var i = 0; i < lowerLayer.length - 1; i++) { + final v = lowerLayer[i]; + final w = lowerLayer[i + 1]; + + // Perform a trial swap + exchange(lowerLayer, v, w); + + // Recalculate total crossings with the more efficient method. + var crossingsAfter = _getBiLayerCrossings(upperLayer, lowerLayer); + + if (crossingsAfter < crossingsBefore) { + // The swap was good, keep it. + improved = true; + changed = true; + crossingsBefore = + crossingsAfter; // Update the baseline crossing count + } else { + // The swap was not beneficial, revert it. + exchange(lowerLayer, w, v); + } + } + } + } + return changed; + } + + /// Calculates the number of crossings between two specific layers using the AccumulatorTree. + int _getBiLayerCrossings(List upperLayer, List lowerLayer) { + if (upperLayer.isEmpty || lowerLayer.isEmpty) { + return 0; + } + + // Update positions in nodeData based on the current list order. + // This is crucial as the transpose function modifies the list directly. + for (var i = 0; i < lowerLayer.length; i++) { + nodeData[lowerLayer[i]]!.position = i; + } + + var targetIndices = []; + // Ensure upper layer nodes are sorted by their original position to maintain a stable sort. + var sortedUpperLayer = List.from(upperLayer) + ..sort((a, b) => nodeData[a]!.position.compareTo(nodeData[b]!.position)); + + for (var source in sortedUpperLayer) { + var successors = successorsOf(source) + .where((succ) => lowerLayer.contains(succ)) + .toList() + ..sort( + (a, b) => nodeData[a]!.position.compareTo(nodeData[b]!.position)); + + for (var successor in successors) { + targetIndices.add(nodeData[successor]!.position); + } + } + + if (targetIndices.isNotEmpty) { + var maxIndex = targetIndices.reduce(max); + var accumTree = AccumulatorTree(maxIndex + 1); + return accumTree.crossCount(targetIndices); + } + + return 0; + } + void exchange(List nodes, Node v, Node w) { var i = nodes.indexOf(v); var j = nodes.indexOf(w); @@ -339,8 +582,8 @@ class SugiyamaAlgorithm extends Algorithm { int crossingCount(HashMap northernNodes, Node? n1, Node? n2) { final indexOf = (Node node) => northernNodes[node]!; var crossing = 0; - final parentNodesN1 = nodeData[n1]!.predecessorNodes; - final parentNodesN2 = nodeData[n2]!.predecessorNodes; + final parentNodesN1 = graph.predecessorsOf(n1); + final parentNodesN2 = graph.predecessorsOf(n2); parentNodesN2.forEach((pn2) { final indexOfPn2 = indexOf(pn2); parentNodesN1.where((it) => indexOfPn2 < indexOf(it)).forEach((element) { @@ -358,7 +601,8 @@ class SugiyamaAlgorithm extends Algorithm { final southernNodes = layers[l]; final northernNodes = layers[l + 1]; - final indexMap = HashMap.of(northernNodes.asMap().map((key, value) => MapEntry(value, key))); + final indexMap = HashMap.of( + northernNodes.asMap().map((key, value) => MapEntry(value, key))); for (var i = 0; i < southernNodes.length - 2; i++) { final v = southernNodes[i]; @@ -378,10 +622,14 @@ class SugiyamaAlgorithm extends Algorithm { graph.nodes.forEach((v) { v.position = getPosition(v, offset); }); + + if (configuration.postStraighten) { + postStraighten(); + } } void assignX() { - // each node points to the root of the block.; + // Existing implementation remains the same final root = >[]; // each node points to its aligned neighbor in the layer below.; final align = >[]; @@ -418,22 +666,15 @@ class SugiyamaAlgorithm extends Algorithm { for (var leftToRight = 0; leftToRight <= 1; leftToRight++) { final k = 2 * downward + leftToRight; var isLeftToRight = leftToRight == 0; - verticalAlignment(root[k], align[k], type1Conflicts, isDownward, isLeftToRight); + verticalAlignment( + root[k], align[k], type1Conflicts, isDownward, isLeftToRight); graph.nodes.forEach((v) { final r = root[k][v]!; - blockWidth[k][r] = max(blockWidth[k][r]!, vertical ? v.width + separation : v.height); + blockWidth[k][r] = max( + blockWidth[k][r]!, vertical ? v.width + separation : v.height); }); - horizontalCompactation( - align[k], - root[k], - sink[k], - shift[k], - blockWidth[k], - x[k], - isLeftToRight, - isDownward, - layers, - separation); + horizontalCompactation(align[k], root[k], sink[k], shift[k], + blockWidth[k], x[k], isLeftToRight, isDownward, layers, separation); } } @@ -543,27 +784,29 @@ class SugiyamaAlgorithm extends Algorithm { resolveOverlaps(coordinates); - graph.nodes.forEach((v) { v.x = coordinates[v]!; }); } void resolveOverlaps(Map coordinates) { - for (var layer in layers) { + for (var layer in layers) { var layerNodes = List.from(layer); - layerNodes.sort((a, b) => nodeData[a]!.position.compareTo(nodeData[b]!.position)); + layerNodes.sort( + (a, b) => nodeData[a]!.position.compareTo(nodeData[b]!.position)); var data = nodeData[layerNodes.first]; if (data?.layer != 0) { var leftCoordinate = 0.0; for (var i = 1; i < layerNodes.length; i++) { var currentNode = layerNodes[i]; - if(!nodeData[currentNode]!.isDummy) { + if (!nodeData[currentNode]!.isDummy) { var previousNode = getPreviousNonDummyNode(layerNodes, i); if (previousNode != null) { - leftCoordinate = coordinates[previousNode]! + previousNode.width + configuration.nodeSeparation; + leftCoordinate = coordinates[previousNode]! + + previousNode.width + + configuration.nodeSeparation; } else { leftCoordinate = 0.0; } @@ -571,7 +814,8 @@ class SugiyamaAlgorithm extends Algorithm { if (leftCoordinate > coordinates[currentNode]!) { var adjustment = leftCoordinate - coordinates[currentNode]!; if (coordinates[currentNode] != null) { - coordinates[currentNode] = coordinates[currentNode]! + adjustment; + coordinates[currentNode] = + coordinates[currentNode]! + adjustment; } } } @@ -606,7 +850,9 @@ class SugiyamaAlgorithm extends Algorithm { * iterate level[2..h-2] in the given direction; * available 1 levels to h; */ - for (var i = lower; downward ? i <= upper : i >= upper; i += downward ? 1 : -1) { + for (var i = lower; + downward ? i <= upper : i >= upper; + i += downward ? 1 : -1) { var k0 = 0; var firstIndex = 0; // index of first node on layer; final currentLevel = layers[i]; @@ -648,8 +894,8 @@ class SugiyamaAlgorithm extends Algorithm { return type1Conflicts; } - void verticalAlignment(Map root, Map align, Map type1Conflicts, - bool downward, bool leftToRight) { + void verticalAlignment(Map root, Map align, + Map type1Conflicts, bool downward, bool leftToRight) { // for all Level; var layersa = downward ? layers : layers.reversed; @@ -666,7 +912,10 @@ class SugiyamaAlgorithm extends Algorithm { // Calculate medians final medians = adjNodes.length % 2 == 1 ? [adjNodes[midLevelValue.floor()]] - : [adjNodes[midLevelValue.toInt() - 1], adjNodes[midLevelValue.toInt()]]; + : [ + adjNodes[midLevelValue.toInt() - 1], + adjNodes[midLevelValue.toInt()] + ]; // For all median neighbours in direction of H for (var m in medians) { @@ -686,9 +935,17 @@ class SugiyamaAlgorithm extends Algorithm { } } - void horizontalCompactation(Map align, Map root, Map sink, - Map shift, Map blockWidth, Map x, bool leftToRight, - bool downward, List> layers, int separation) { + void horizontalCompactation( + Map align, + Map root, + Map sink, + Map shift, + Map blockWidth, + Map x, + bool leftToRight, + bool downward, + List> layers, + int separation) { // calculate class relative coordinates for all roots; // If the layers are traversed from right to left, a reverse iterator is needed (note that this does not change the original list of layers) var layersa = leftToRight ? layers : layers.reversed; @@ -699,7 +956,8 @@ class SugiyamaAlgorithm extends Algorithm { // Do an initial placement for all blocks for (var v in nodes) { if (root[v] == v) { - placeBlock(v, sink, shift, x, align, blockWidth, root, leftToRight, layers, separation); + placeBlock(v, sink, shift, x, align, blockWidth, root, leftToRight, + layers, separation); } } } @@ -732,8 +990,17 @@ class SugiyamaAlgorithm extends Algorithm { }); } - void placeBlock(Node v, Map sink, Map shift, Map x, - Map align, Map blockWidth, Map root, bool leftToRight, List> layers, int separation) { + void placeBlock( + Node v, + Map sink, + Map shift, + Map x, + Map align, + Map blockWidth, + Map root, + bool leftToRight, + List> layers, + int separation) { if (x[v] == double.negativeInfinity) { x[v] = 0; var currentNode = v; @@ -741,16 +1008,19 @@ class SugiyamaAlgorithm extends Algorithm { try { do { // if not first node on layer; - final hasPredecessor = leftToRight && positionOfNode(currentNode) > 0 || - !leftToRight && - positionOfNode(currentNode) < layers[getLayerIndex(currentNode)].length - 1; + final hasPredecessor = + leftToRight && positionOfNode(currentNode) > 0 || + !leftToRight && + positionOfNode(currentNode) < + layers[getLayerIndex(currentNode)].length - 1; // print("Pred $hasPredecessor ${getLayerIndex(currentNode)>0} ${positionOfNode(currentNode)>0}"); if (hasPredecessor) { final pred = predecessor(currentNode, leftToRight); /* Get the root of u (proceeding all the way upwards in the block) */ final u = root[pred]!; /* Place the block of u recursively */ - placeBlock(u, sink, shift, x, align, blockWidth, root, leftToRight, layers, separation); + placeBlock(u, sink, shift, x, align, blockWidth, root, leftToRight, + layers, separation); /* If v is its own sink yet, set its sink to the sink of u */ if (sink[v] == v) { sink[v] = sink[u]!; @@ -829,7 +1099,9 @@ class SugiyamaAlgorithm extends Algorithm { bool isLongEdgeDummy(Node? v) { final successors = successorsOf(v); - return nodeData[v!]!.isDummy && successors.length == 1 && nodeData[successors[0]]!.isDummy; + return nodeData[v!]!.isDummy && + successors.length == 1 && + nodeData[successors[0]]!.isDummy; } void assignY() { @@ -870,9 +1142,11 @@ class SugiyamaAlgorithm extends Algorithm { if (nodeData[current]!.isDummy) { final predecessor = graph.predecessorsOf(current)[0]; final successor = graph.successorsOf(current)[0]; - final bendPoints = edgeData[graph.getEdgeBetween(predecessor, current)!]!.bendPoints; + final bendPoints = + edgeData[graph.getEdgeBetween(predecessor, current)!]!.bendPoints; - if (bendPoints.isEmpty || !bendPoints.contains(current.x + predecessor.width / 2)) { + if (bendPoints.isEmpty || + !bendPoints.contains(current.x + predecessor.width / 2)) { bendPoints.add(predecessor.x + predecessor.width / 2); bendPoints.add(predecessor.y + predecessor.height / 2); bendPoints.add(current.x + predecessor.width / 2); @@ -909,7 +1183,8 @@ class SugiyamaAlgorithm extends Algorithm { graph.nodes.forEach((n) { if (nodeData[n]!.isReversed) { nodeData[n]!.reversed.forEach((target) { - final bendPoints = this.edgeData[graph.getEdgeBetween(target, n)!]!.bendPoints; + final bendPoints = + this.edgeData[graph.getEdgeBetween(target, n)!]!.bendPoints; graph.removeEdgeFromPredecessor(target, n); final edge = graph.addEdge(n, target); @@ -921,6 +1196,99 @@ class SugiyamaAlgorithm extends Algorithm { }); } + void cycleRemoval() { + switch (configuration.cycleRemovalStrategy) { + case CycleRemovalStrategy.dfs: + _dfsRecursiveCycleRemoval(); + break; + case CycleRemovalStrategy.greedy: + _greedyCycleRemoval(); + break; + } + } + + void _dfsRecursiveCycleRemoval() { + graph.nodes.forEach((node) { + dfs(node); + }); + } + + void _greedyCycleRemoval() { + var greedyRemoval = GreedyCycleRemoval(graph); + var feedbackArcs = greedyRemoval.getFeedbackArcs(); + + for (var edge in feedbackArcs) { + var source = edge.source; + var target = edge.destination; + graph.removeEdge(edge); + graph.addEdge(target, source); + nodeData[source]!.reversed.add(target); + } + } + + void postStraighten() { + if (!configuration.postStraighten) return; + + // Align dummy vertices to create straighter edges + var dummyNodes = []; + for (var layer in layers) { + dummyNodes.addAll(layer.where((n) => nodeData[n]!.isDummy)); + } + + // Group dummy nodes by their original edge + var edgeGroups = >[]; + var processed = {}; + + for (var dummy in dummyNodes) { + if (processed.contains(dummy)) continue; + + var group = [dummy]; + processed.add(dummy); + + // Find connected dummy nodes (same edge) + _findConnectedDummies(dummy, group, processed, dummyNodes); + + if (group.length > 1) { + edgeGroups.add(group); + } + } + + // Align each group vertically + for (var group in edgeGroups) { + group.sort((a, b) => nodeData[a]!.layer.compareTo(nodeData[b]!.layer)); + + // Calculate average x position + var avgX = group.map((n) => n.x).reduce((a, b) => a + b) / group.length; + + // Set all dummy nodes to average x + for (var node in group) { + node.x = avgX; + } + } + } + + void _findConnectedDummies( + Node current, List group, Set processed, List dummies) { + var successors = successorsOf(current); + var predecessors = predecessorsOf(current); + + for (var target in successors) { + if (dummies.contains(target) && !processed.contains(target)) { + group.add(target); + processed.add(target); + _findConnectedDummies(target, group, processed, dummies); + } + } + + for (var source in predecessors) { + if (dummies.contains(source) && !processed.contains(source)) { + group.add(source); + processed.add(source); + _findConnectedDummies(source, group, processed, dummies); + } + } + } + Offset getOffset(Graph graph, bool needReverseOrder) { var offsetX = double.infinity; var offsetY = double.infinity; @@ -965,9 +1333,6 @@ class SugiyamaAlgorithm extends Algorithm { return finalOffset; } - @override - void setFocusedNode(Node node) {} - @override void init(Graph? graph) { this.graph = copyGraph(graph!); @@ -984,23 +1349,98 @@ class SugiyamaAlgorithm extends Algorithm { // shiftCoordinates(graph, shiftX, shiftY); } - @override - void step(Graph? graph) { - reset(); - initSugiyamaData(); - cycleRemoval(); - layerAssignment(); - nodeOrdering(); //expensive operation - coordinateAssignment(); //expensive operation - // shiftCoordinates(shiftX, shiftY); - //final graphSize = calculateGraphSize(this.graph); - denormalize(); - restoreCycle(); - } - @override void setDimensions(double width, double height) { // graphWidth = width; // graphHeight = height; } } + +class AccumulatorTree { + late List tree; + late int firstIndex; + late int treeSize; + late int base; + late int last; + + AccumulatorTree(int size) { + firstIndex = 1; + while (firstIndex < size) { + firstIndex *= 2; + } + treeSize = 2 * firstIndex - 1; + firstIndex--; + base = size - 1; + last = size - 1; + tree = List.filled(treeSize, 0); + } + + int crossCount(List southSequence) { + var crossCount = 0; + for (var k = 0; k < southSequence.length; k++) { + var index = southSequence[k] + firstIndex; + tree[index]++; + while (index > 0) { + if (index % 2 != 0) { + crossCount += tree[index + 1]; + } + index = (index - 1) ~/ 2; + tree[index]++; + } + } + return crossCount; + } +} + +class GreedyCycleRemoval { + final Graph graph; + final Set feedbackArcs = {}; + + GreedyCycleRemoval(this.graph); + + Set getFeedbackArcs() { + var copy = _copyGraph(); + _removeCycles(copy); + return feedbackArcs; + } + + Graph _copyGraph() { + var copy = Graph(); + copy.addNodes(graph.nodes); + copy.addEdges(graph.edges); + return copy; + } + + void _removeCycles(Graph g) { + while (g.nodes.isNotEmpty) { + // Remove sinks + var sinks = g.nodes.where((n) => !g.hasSuccessor(n)).toList(); + if (sinks.isNotEmpty) { + for (var sink in sinks) { + g.removeNode(sink); + } + continue; + } + + // Remove sources + var sources = g.nodes.where((n) => !g.hasPredecessor(n)).toList(); + if (sources.isNotEmpty) { + for (var source in sources) { + g.removeNode(source); + } + continue; + } + + // Choose nodes with highest out-degree - in-degree + var best = g.nodes.reduce((a, b) { + var aDiff = g.getOutEdges(a).length - g.getInEdges(a).length; + var bDiff = g.getOutEdges(b).length - g.getInEdges(b).length; + return aDiff > bDiff ? a : b; + }); + + // Add incoming edges to feedback arcs + feedbackArcs.addAll(g.getInEdges(best)); + g.removeNode(best); + } + } +} diff --git a/lib/layered/SugiyamaConfiguration.dart b/lib/layered/SugiyamaConfiguration.dart index a8641b7..574204c 100644 --- a/lib/layered/SugiyamaConfiguration.dart +++ b/lib/layered/SugiyamaConfiguration.dart @@ -18,6 +18,12 @@ class SugiyamaConfiguration { BendPointShape bendPointShape = SharpBendPointShape(); CoordinateAssignment coordinateAssignment = CoordinateAssignment.Average; + LayeringStrategy layeringStrategy = LayeringStrategy.topDown; + CrossMinimizationStrategy crossMinimizationStrategy = CrossMinimizationStrategy.simple; + CycleRemovalStrategy cycleRemovalStrategy = CycleRemovalStrategy.greedy; + + bool postStraighten = true; + bool addTriangleToEdge = true; int getLevelSeparation() { @@ -41,6 +47,23 @@ enum CoordinateAssignment { Average, // 4 } +enum LayeringStrategy { + topDown, + longestPath, + coffmanGraham, + networkSimplex +} + +enum CrossMinimizationStrategy { + simple, + accumulatorTree +} + +enum CycleRemovalStrategy { + dfs, + greedy, +} + abstract class BendPointShape {} class SharpBendPointShape extends BendPointShape {} diff --git a/lib/layered/SugiyamaEdgeRenderer.dart b/lib/layered/SugiyamaEdgeRenderer.dart index bbea871..fd385fe 100644 --- a/lib/layered/SugiyamaEdgeRenderer.dart +++ b/lib/layered/SugiyamaEdgeRenderer.dart @@ -18,9 +18,6 @@ class SugiyamaEdgeRenderer extends ArrowEdgeRenderer { ..style = PaintingStyle.fill; graph.edges.forEach((edge) { - final source = edge.source; - final destination = edge.destination; - Paint? edgeTrianglePaint; if (edge.paint != null) { edgeTrianglePaint = Paint() @@ -32,15 +29,16 @@ class SugiyamaEdgeRenderer extends ArrowEdgeRenderer { ..style = PaintingStyle.stroke; if (hasBendEdges(edge)) { - _renderEdgeWithBendPoints(canvas, edge, source, destination, currentPaint, edgeTrianglePaint ?? trianglePaint); + _renderEdgeWithBendPoints(canvas, edge, currentPaint, edgeTrianglePaint ?? trianglePaint); } else { - _renderStraightEdge(canvas, edge, source, destination, currentPaint, edgeTrianglePaint ?? trianglePaint); + _renderStraightEdge(canvas, edge, currentPaint, edgeTrianglePaint ?? trianglePaint); } }); } - void _renderEdgeWithBendPoints(Canvas canvas, Edge edge, Node source, - Node destination, Paint currentPaint, Paint trianglePaint) { + void _renderEdgeWithBendPoints(Canvas canvas, Edge edge, Paint currentPaint, Paint trianglePaint) { + final source = edge.source; + final destination = edge.destination; var bendPoints = edgeData[edge]!.bendPoints; var sourceCenter = _getNodeCenter(source); @@ -88,7 +86,7 @@ class SugiyamaEdgeRenderer extends ArrowEdgeRenderer { var clippedLine = []; final size = bendPoints.length; if (nodeData[source]!.isReversed) { - clippedLine = clipLineEnd(bendPoints[2], bendPoints[3], stopX, stopY, destination.x , + clippedLine = clipLineEnd(bendPoints[2], bendPoints[3], stopX, stopY, destination.x, destination.y, destination.width, destination.height); } else { clippedLine = clipLineEnd(bendPoints[size - 4], bendPoints[size - 3], @@ -103,8 +101,9 @@ class SugiyamaEdgeRenderer extends ArrowEdgeRenderer { canvas.drawPath(path, currentPaint); } - void _renderStraightEdge(Canvas canvas, Edge edge, Node source, - Node destination, Paint currentPaint, Paint trianglePaint) { + void _renderStraightEdge(Canvas canvas, Edge edge, Paint currentPaint, Paint trianglePaint) { + final source = edge.source; + final destination = edge.destination; final sourceCenter = _getNodeCenter(source); var destCenter = _getNodeCenter(destination); @@ -116,22 +115,9 @@ class SugiyamaEdgeRenderer extends ArrowEdgeRenderer { destCenter = drawTriangle(canvas, trianglePaint, clippedLine[0], clippedLine[1], clippedLine[2], clippedLine[3]); } - // Draw the line - switch (nodeData[destination]?.lineType) { - case LineType.DashedLine: - _drawDashedLine(canvas, sourceCenter, destCenter, currentPaint, 0.6); - break; - case LineType.DottedLine: - // dotted line uses the same method as dashed line, but with a lineLength of 0.0 - _drawDashedLine(canvas, sourceCenter, destCenter, currentPaint, 0.0); - break; - case LineType.SineLine: - _drawSineLine(canvas, sourceCenter, destCenter, currentPaint); - break; - default: - canvas.drawLine(sourceCenter, destCenter, currentPaint); - break; - } + // Draw the line with appropriate line type using the base class method + final lineType = nodeData[destination]?.lineType; + drawStyledLine(canvas, sourceCenter, destCenter, currentPaint, lineType: lineType); } void _drawSharpBendPointsEdge(List bendPoints) { @@ -169,85 +155,4 @@ class SugiyamaEdgeRenderer extends ArrowEdgeRenderer { } } } - - void _drawDashedLine(Canvas canvas, Offset source, Offset destination, - Paint paint, double lineLength) { - var dx = destination.dx - source.dx; - var dy = destination.dy - source.dy; - - // Calculate the Euclidean distance - var distance = sqrt(dx * dx + dy * dy); - - var numLines = lineLength == 0.0 ? (distance / 5).ceil() : 14; - - // Calculate the step size for each line - var stepX = dx / numLines; - var stepY = dy / numLines; - - var circleRadius = 1.0; - - var circleStrokeWidth = 1.0; - var circlePaint = Paint() - ..color = paint.color - ..strokeWidth = circleStrokeWidth - ..style = PaintingStyle.fill; // Change to fill style - - // Draw the lines or dots between the two points - for (var i = 0; i < numLines; i++) { - var startX = source.dx + (i * stepX); - var startY = source.dy + (i * stepY); - if (lineLength == 0.0) { - // Draw a dot with a fixed radius and stroke width - canvas.drawCircle(Offset(startX, startY), circleRadius, circlePaint); - } else { - // Draw a dash - var endX = startX + (stepX * lineLength); - var endY = startY + (stepY * lineLength); - canvas.drawLine(Offset(startX, startY), Offset(endX, endY), paint); - } - } - } - - void _drawSineLine(Canvas canvas, Offset source, Offset destination, Paint paint) { - paint..strokeWidth = 1.5; - - final dx = destination.dx - source.dx; - final dy = destination.dy - source.dy; - final distance = sqrt(dx * dx + dy * dy); - final lineLength = 6; - var phaseOffset = 2; - - // Verify dx and dy to avoid NaN to Offset() - if (dx != 0 || dy != 0) { - var distanceTraveled = 0.0; - var phase = 0.0; - final path = Path()..moveTo(source.dx, source.dy); - - while (distanceTraveled < distance) { - final segmentLength = min(lineLength, distance - distanceTraveled); - final segmentFraction = segmentLength / distance; - final segmentDestination = Offset( - source.dx + dx * segmentFraction, - source.dy + dy * segmentFraction, - ); - - final y = sin(phase + phaseOffset) * segmentLength; - - num x; - if ((dx > 0 && dy < 0) || (dx < 0 && dy > 0)) { - x = sin(phase + phaseOffset) * segmentLength; - } else { - // dx < 0 && dy < 0 - x = -sin(phase + phaseOffset) * segmentLength; - } - - path.lineTo(segmentDestination.dx + x, segmentDestination.dy + y); - - distanceTraveled += segmentLength; - source = segmentDestination; - phase += pi * segmentLength / lineLength; - } - canvas.drawPath(path, paint); - } - } -} +} \ No newline at end of file diff --git a/lib/mindmap/MindMapAlgorithm.dart b/lib/mindmap/MindMapAlgorithm.dart new file mode 100644 index 0000000..171a90c --- /dev/null +++ b/lib/mindmap/MindMapAlgorithm.dart @@ -0,0 +1,113 @@ +part of graphview; + +enum MindmapSide { LEFT, RIGHT, ROOT } + +class _SideData { + MindmapSide side = MindmapSide.ROOT; +} + +class MindmapAlgorithm extends BuchheimWalkerAlgorithm { + final Map _side = {}; + + MindmapAlgorithm(BuchheimWalkerConfiguration config, EdgeRenderer? renderer) + : super(config, renderer ?? MindmapEdgeRenderer(config)); + + @override + void initData(Graph? graph) { + super.initData(graph); + _side.clear(); + graph?.nodes.forEach((n) => _side[n] = _SideData()); + } + + @override + Size run(Graph? graph, double shiftX, double shiftY) { + initData(graph); + _detectCycles(graph!); + final root = getFirstNode(graph); + _applyBuchheimWalkerSpacing(graph, root); + _createMindmapLayout(graph, root); + shiftCoordinates(graph, shiftX, shiftY); + return graph.calculateGraphSize(); + } + + void _markSubtree(Node node, MindmapSide side) { + final d = _side[node]!; + d.side = side; + + for (final child in successorsOf(node)) { + _markSubtree(child, side); + } + } + + void _applyBuchheimWalkerSpacing(Graph graph, Node root) { + // Apply the standard Buchheim-Walker algorithm to get proper spacing + // This gives us optimal spacing relationships between all nodes + firstWalk(graph, root, 0, 0); + secondWalk(graph, root, 0.0); + positionNodes(graph); + + // At this point, all nodes have positions with proper spacing, + // but they're in a traditional tree layout. We'll reposition them next. + } + + void _createMindmapLayout(Graph graph, Node root) { + final vertical = isVertical(); + final rootPos = vertical ? root.x : root.y; + + // Mark subtrees and position nodes in one pass + for (final child in successorsOf(root)) { + final childPos = vertical ? child.x : child.y; + final side = childPos < rootPos ? MindmapSide.LEFT : MindmapSide.RIGHT; + _markSubtree(child, side); + } + + // Position all non-root nodes + for (final node in graph.nodes) { + final info = nodeData[node]!; + if (info.depth == 0) continue; // Skip root + + final sideMultiplier = _side[node]!.side == MindmapSide.LEFT ? -1 : 1; + final secondary = vertical ? node.x : node.y; + final distanceFromRoot = info.depth * configuration.levelSeparation + + (vertical ? maxNodeWidth : maxNodeHeight) / 2; + + if (vertical) { + node.position = Offset( + secondary - root.x * 0.5 * sideMultiplier, + sideMultiplier * distanceFromRoot + ); + } else { + node.position = Offset( + sideMultiplier * distanceFromRoot, + secondary - root.y * 0.5 * sideMultiplier + ); + } + } + + // Adjust root and apply final transformations + if (needReverseOrder()) { + if (vertical) { + root.y = 0.0; + } else { + root.x = 0.0; + } + } + + for (final node in graph.nodes) { + final info = nodeData[node]!; + if (info.depth == 0) { + if (vertical) { + node.x = node.x * 0.5; + } else { + node.y = node.y * 0.5; + } + } else { + if (vertical) { + node.x = node.x - root.x; + } else { + node.y = node.y - root.y; + } + } + } + } +} diff --git a/lib/mindmap/MindmapEdgeRenderer.dart b/lib/mindmap/MindmapEdgeRenderer.dart new file mode 100644 index 0000000..a82a637 --- /dev/null +++ b/lib/mindmap/MindmapEdgeRenderer.dart @@ -0,0 +1,27 @@ +part of graphview; + +class MindmapEdgeRenderer extends TreeEdgeRenderer { + MindmapEdgeRenderer(BuchheimWalkerConfiguration configuration) + : super(configuration); + + @override + int getEffectiveOrientation(dynamic node, dynamic child) { + var orientation = configuration.orientation; + + if (child.y < 0) { + if (configuration.orientation == BuchheimWalkerConfiguration.ORIENTATION_TOP_BOTTOM) { + orientation = BuchheimWalkerConfiguration.ORIENTATION_BOTTOM_TOP; + } else { + // orientation = BuchheimWalkerConfiguration.ORIENTATION_TOP_BOTTOM; + } + } else if (child.x < 0) { + if (configuration.orientation == BuchheimWalkerConfiguration.ORIENTATION_LEFT_RIGHT) { + orientation = BuchheimWalkerConfiguration.ORIENTATION_RIGHT_LEFT; + } else { + orientation = BuchheimWalkerConfiguration.ORIENTATION_LEFT_RIGHT; + } + } + + return orientation; + } +} \ No newline at end of file diff --git a/lib/tree/BaloonLayoutAlgorithm.dart b/lib/tree/BaloonLayoutAlgorithm.dart new file mode 100644 index 0000000..432a6b0 --- /dev/null +++ b/lib/tree/BaloonLayoutAlgorithm.dart @@ -0,0 +1,261 @@ +part of graphview; + +// Polar coordinate representation +class PolarPoint { + final double theta; // angle in radians + final double radius; + + const PolarPoint(this.theta, this.radius); + + static const PolarPoint origin = PolarPoint(0, 0); + + // Convert polar coordinates to cartesian + Offset toCartesian() { + final x = radius * cos(theta); + final y = radius * sin(theta); + return Offset(x, y); + } + + // Create polar point from angle and radius + static PolarPoint of(double theta, double radius) { + return PolarPoint(theta, radius); + } + + @override + String toString() => 'PolarPoint(theta: $theta, radius: $radius)'; +} + +class BalloonLayoutAlgorithm extends Algorithm { + late BuchheimWalkerConfiguration config; + final Map nodeData = {}; + final Map polarLocations = {}; + final Map radii = {}; + + BalloonLayoutAlgorithm(this.config, EdgeRenderer? renderer) { + this.renderer = renderer ?? ArrowEdgeRenderer(); + } + + @override + Size run(Graph? graph, double shiftX, double shiftY) { + if (graph == null || graph.nodes.isEmpty) { + return Size.zero; + } + + nodeData.clear(); + polarLocations.clear(); + radii.clear(); + + // Handle single node case + if (graph.nodes.length == 1) { + final node = graph.nodes.first; + node.position = Offset(shiftX + 100, shiftY + 100); + return Size(200, 200); + } + + _initializeData(graph); + final roots = _findRoots(graph); + + if (roots.isEmpty) { + final spanningTree = _createSpanningTree(graph); + return _layoutSpanningTree(spanningTree, shiftX, shiftY); + } + + _setRootPolars(graph, roots); + _shiftCoordinates(graph, shiftX, shiftY); + return graph.calculateGraphSize(); + } + + void _initializeData(Graph graph) { + // Initialize node data + for (final node in graph.nodes) { + nodeData[node] = TreeLayoutNodeData(); + } + + // Build tree structure from edges + for (final edge in graph.edges) { + final source = edge.source; + final target = edge.destination; + + nodeData[source]!.successorNodes.add(target); + nodeData[target]!.parent = source; + } + } + + List _findRoots(Graph graph) { + return graph.nodes.where((node) { + return nodeData[node]!.parent == null; + }).toList(); + } + + void _setRootPolars(Graph graph, List roots) { + final center = _getGraphCenter(graph); + final width = graph.calculateGraphBounds().width; + final defaultRadius = max(width / 2, 200.0); + + if (roots.length == 1) { + // Single tree - place root at center + final root = roots.first; + _setRootPolar(root, center); + final children = successorsOf(root); + _setPolars(children, center, 0, defaultRadius, {}); + } else if (roots.length > 1) { + // Multiple trees - arrange roots in circle + _setPolars(roots, center, 0, defaultRadius, {}); + } + } + + void _setRootPolar(Node root, Offset center) { + polarLocations[root] = PolarPoint.origin; + root.position = center; + } + + void _setPolars(List nodes, Offset parentLocation, double angleToParent, + double parentRadius, Set seen) { + final childCount = nodes.length; + if (childCount == 0) return; + + // Calculate child placement parameters + final angle = max(0, pi / 2 * (1 - 2.0 / childCount)); + final childRadius = parentRadius * cos(angle) / (1 + cos(angle)); + final radius = parentRadius - childRadius; + + // Angle between children + final angleBetweenKids = 2 * pi / childCount; + final offset = angleBetweenKids / 2 - angleToParent; + + for (int i = 0; i < nodes.length; i++) { + final child = nodes[i]; + if (seen.contains(child)) continue; + + // Calculate angle for this child + final theta = i * angleBetweenKids + offset; + + // Store radius and polar coordinates + radii[child] = childRadius; + final polarPoint = PolarPoint.of(theta, radius); + polarLocations[child] = polarPoint; + + // Convert to cartesian and position node + final cartesian = polarPoint.toCartesian(); + final position = Offset( + parentLocation.dx + cartesian.dx, + parentLocation.dy + cartesian.dy, + ); + child.position = position; + + final newAngleToParent = atan2( + parentLocation.dy - position.dy, + parentLocation.dx - position.dx, + ); + + final grandChildren = successorsOf(child) + .where((node) => !seen.contains(node)) + .toList(); + + if (grandChildren.isNotEmpty) { + final newSeen = Set.from(seen); + newSeen.add(child); // Add current child to prevent cycles + _setPolars(grandChildren, position, newAngleToParent, childRadius, newSeen); + } + } + } + + Offset _getGraphCenter(Graph graph) { + final bounds = graph.calculateGraphBounds(); + return Offset( + bounds.left + bounds.width / 2, + bounds.top + bounds.height / 2, + ); + } + + void _shiftCoordinates(Graph graph, double shiftX, double shiftY) { + for (final node in graph.nodes) { + node.position = Offset(node.x + shiftX, node.y + shiftY); + } + } + + Graph _createSpanningTree(Graph graph) { + final visited = {}; + final spanningEdges = []; + + if (graph.nodes.isNotEmpty) { + final startNode = graph.nodes.first; + final queue = [startNode]; + visited.add(startNode); + + while (queue.isNotEmpty) { + final current = queue.removeAt(0); + + for (final edge in graph.edges) { + Node? neighbor; + if (edge.source == current && !visited.contains(edge.destination)) { + neighbor = edge.destination; + spanningEdges.add(edge); + } else if (edge.destination == current && !visited.contains(edge.source)) { + neighbor = edge.source; + spanningEdges.add(Edge(current, edge.source)); + } + + if (neighbor != null && !visited.contains(neighbor)) { + visited.add(neighbor); + queue.add(neighbor); + } + } + } + } + + return Graph()..addEdges(spanningEdges); + } + + Size _layoutSpanningTree(Graph spanningTree, double shiftX, double shiftY) { + nodeData.clear(); + polarLocations.clear(); + radii.clear(); + + _initializeData(spanningTree); + final roots = _findRoots(spanningTree); + + if (roots.isEmpty && spanningTree.nodes.isNotEmpty) { + final fakeRoot = spanningTree.nodes.first; + _setRootPolars(spanningTree, [fakeRoot]); + } else { + _setRootPolars(spanningTree, roots); + } + + _shiftCoordinates(spanningTree, shiftX, shiftY); + return spanningTree.calculateGraphSize(); + } + + List successorsOf(Node? node) { + return nodeData[node]!.successorNodes; + } + + PolarPoint? getPolarLocation(Node node) { + return polarLocations[node]; + } + + double? getRadius(Node node) { + return radii[node]; + } + + Map getRadii() { + return Map.from(radii); + } + + Map getPolarLocations() { + return Map.from(polarLocations); + } + + @override + void init(Graph? graph) { + // Implementation can be added if needed + } + + @override + void setDimensions(double width, double height) { + // Implementation can be added if needed + } + + @override + EdgeRenderer? renderer; +} \ No newline at end of file diff --git a/lib/tree/BuchheimWalkerAlgorithm.dart b/lib/tree/BuchheimWalkerAlgorithm.dart index 9d9289f..9dda287 100644 --- a/lib/tree/BuchheimWalkerAlgorithm.dart +++ b/lib/tree/BuchheimWalkerAlgorithm.dart @@ -21,12 +21,12 @@ class BuchheimWalkerAlgorithm extends Algorithm { } void _detectCycles(Graph graph) { - Set visiting = {}; + var visiting = {}; bool hasCycle(Node node) { if (visiting.contains(node)) return true; visiting.add(node); - bool cycleFound = successorsOf(node).any(hasCycle); + var cycleFound = successorsOf(node).any(hasCycle); visiting.remove(node); return cycleFound; } @@ -38,19 +38,26 @@ class BuchheimWalkerAlgorithm extends Algorithm { @override Size run(Graph? graph, double shiftX, double shiftY) { + if (graph == null) return Size.zero; nodeData.clear(); + if (graph.nodes.length == 1) { + final node = graph.nodes.first; + node.position = Offset(shiftX, shiftY); + return node.size * 2; + } initData(graph); - _detectCycles(graph!); - var firstNode = getFirstNode(graph!); + _detectCycles(graph); + var firstNode = getFirstNode(graph); firstWalk(graph, firstNode, 0, 0); secondWalk(graph, firstNode, 0.0); checkUnconnectedNotes(graph); positionNodes(graph); shiftCoordinates(graph, shiftX, shiftY); - return calculateGraphSize(graph); + return graph.calculateGraphSize(); } - Node getFirstNode(Graph graph) => graph.nodes.firstWhere((element) => !hasPredecessor(element)); + Node getFirstNode(Graph graph) => + graph.nodes.firstWhere((element) => !hasPredecessor(element)); void checkUnconnectedNotes(Graph graph) { graph.nodes.forEach((element) { @@ -126,22 +133,6 @@ class BuchheimWalkerAlgorithm extends Algorithm { }); } - Size calculateGraphSize(Graph graph) { - var left = double.infinity; - var top = double.infinity; - var right = double.negativeInfinity; - var bottom = double.negativeInfinity; - - graph.nodes.forEach((node) { - left = min(left, node.x); - top = min(top, node.y); - right = max(right, node.x + node.width); - bottom = max(bottom, node.y + node.height); - }); - - return Size(right - left, bottom - top); - } - void executeShifts(Graph graph, Node node) { var shift = 0.0; var change = 0.0; @@ -164,7 +155,7 @@ class BuchheimWalkerAlgorithm extends Algorithm { if (hasLeftSibling(graph, node)) { var leftSibling = getLeftSibling(graph, node); Node? vop = node; - Node? vom = getLeftMostChild(graph, graph.predecessorsOf(node).first); + Node? vom = getLeftMostChild(graph, predecessorsOf(node).first); var sip = getModifier(node); var sop = getModifier(node); @@ -506,9 +497,6 @@ class BuchheimWalkerAlgorithm extends Algorithm { this.renderer = renderer ?? TreeEdgeRenderer(configuration); } - @override - void setFocusedNode(Node node) {} - @override void init(Graph? graph) { var firstNode = getFirstNode(graph!); @@ -519,15 +507,6 @@ class BuchheimWalkerAlgorithm extends Algorithm { // shiftCoordinates(graph, shiftX, shiftY); } - @override - void step(Graph? graph) { - var firstNode = getFirstNode(graph!); - firstWalk(graph, firstNode, 0, 0); - secondWalk(graph, firstNode, 0.0); - checkUnconnectedNotes(graph); - positionNodes(graph); - } - @override void setDimensions(double width, double height) { // graphWidth = width; diff --git a/lib/tree/BuchheimWalkerConfiguration.dart b/lib/tree/BuchheimWalkerConfiguration.dart index bc1c059..bf7228d 100644 --- a/lib/tree/BuchheimWalkerConfiguration.dart +++ b/lib/tree/BuchheimWalkerConfiguration.dart @@ -13,6 +13,7 @@ class BuchheimWalkerConfiguration { static const DEFAULT_SUBTREE_SEPARATION = 100; static const DEFAULT_LEVEL_SEPARATION = 100; static const DEFAULT_ORIENTATION = 1; + bool useCurvedConnections = true; int getSiblingSeparation() { return siblingSeparation; @@ -25,4 +26,10 @@ class BuchheimWalkerConfiguration { int getSubtreeSeparation() { return subtreeSeparation; } + BuchheimWalkerConfiguration( + {this.siblingSeparation = DEFAULT_SIBLING_SEPARATION, + this.levelSeparation = DEFAULT_LEVEL_SEPARATION, + this.subtreeSeparation = DEFAULT_SUBTREE_SEPARATION, + this.orientation = DEFAULT_ORIENTATION}); + } diff --git a/lib/tree/CircleLayoutAlgorithm.dart b/lib/tree/CircleLayoutAlgorithm.dart new file mode 100644 index 0000000..3f188cf --- /dev/null +++ b/lib/tree/CircleLayoutAlgorithm.dart @@ -0,0 +1,278 @@ +part of graphview; + +class CircleLayoutConfiguration { + final double radius; + final bool reduceEdgeCrossing; + final int reduceEdgeCrossingMaxEdges; + + CircleLayoutConfiguration({ + this.radius = 0.0, // 0 means auto-calculate + this.reduceEdgeCrossing = true, + this.reduceEdgeCrossingMaxEdges = 200, + }); +} + +class CircleLayoutAlgorithm extends Algorithm { + final CircleLayoutConfiguration config; + double _radius = 0.0; + List nodeOrderedList = []; + + CircleLayoutAlgorithm(this.config, EdgeRenderer? renderer) { + this.renderer = renderer ?? ArrowEdgeRenderer(); + _radius = config.radius; + } + + @override + Size run(Graph? graph, double shiftX, double shiftY) { + if (graph == null || graph.nodes.isEmpty) { + return Size.zero; + } + + // Handle single node case + if (graph.nodes.length == 1) { + final node = graph.nodes.first; + node.position = Offset(shiftX + 100, shiftY + 100); + return Size(200, 200); + } + + _computeNodeOrder(graph); + final size = _layoutNodes(graph); + _shiftCoordinates(graph, shiftX, shiftY); + + return size; + } + + void _computeNodeOrder(Graph graph) { + final shouldReduceCrossing = config.reduceEdgeCrossing && + graph.edges.length < config.reduceEdgeCrossingMaxEdges; + + if (shouldReduceCrossing) { + nodeOrderedList = _reduceEdgeCrossing(graph); + } else { + nodeOrderedList = List.from(graph.nodes); + } + } + + List _reduceEdgeCrossing(Graph graph) { + // Check if graph has multiple components + final components = _findConnectedComponents(graph); + final orderedList = []; + + if (components.length > 1) { + // Handle each component separately + for (final component in components) { + final componentGraph = _createSubgraph(graph, component); + final componentOrder = _optimizeNodeOrder(componentGraph); + orderedList.addAll(componentOrder); + } + } else { + // Single component + orderedList.addAll(_optimizeNodeOrder(graph)); + } + + return orderedList; + } + + List> _findConnectedComponents(Graph graph) { + final visited = {}; + final components = >[]; + + for (final node in graph.nodes) { + if (!visited.contains(node)) { + final component = {}; + _dfsComponent(graph, node, visited, component); + components.add(component); + } + } + + return components; + } + + void _dfsComponent(Graph graph, Node node, Set visited, Set component) { + visited.add(node); + component.add(node); + + for (final edge in graph.edges) { + Node? neighbor; + if (edge.source == node && !visited.contains(edge.destination)) { + neighbor = edge.destination; + } else if (edge.destination == node && !visited.contains(edge.source)) { + neighbor = edge.source; + } + + if (neighbor != null) { + _dfsComponent(graph, neighbor, visited, component); + } + } + } + + Graph _createSubgraph(Graph originalGraph, Set nodes) { + final subgraph = Graph(); + + // Add nodes + for (final node in nodes) { + subgraph.addNode(node); + } + + // Add edges within the component + for (final edge in originalGraph.edges) { + if (nodes.contains(edge.source) && nodes.contains(edge.destination)) { + subgraph.addEdgeS(edge); + } + } + + return subgraph; + } + + List _optimizeNodeOrder(Graph graph) { + if (graph.nodes.length <= 2) { + return List.from(graph.nodes); + } + + // Simple greedy optimization to reduce edge crossings + var bestOrder = List.from(graph.nodes); + var bestCrossings = _countCrossings(graph, bestOrder); + + // Try a few different starting arrangements + final attempts = min(10, graph.nodes.length); + + for (var attempt = 0; attempt < attempts; attempt++) { + var currentOrder = List.from(graph.nodes); + + // Shuffle starting order + if (attempt > 0) { + currentOrder.shuffle(); + } + + // Local optimization: try swapping adjacent nodes + var improved = true; + var iterations = 0; + const maxIterations = 50; + + while (improved && iterations < maxIterations) { + improved = false; + iterations++; + + for (var i = 0; i < currentOrder.length - 1; i++) { + // Try swapping positions i and i+1 + final temp = currentOrder[i]; + currentOrder[i] = currentOrder[i + 1]; + currentOrder[i + 1] = temp; + + final crossings = _countCrossings(graph, currentOrder); + + if (crossings < bestCrossings) { + bestOrder = List.from(currentOrder); + bestCrossings = crossings; + improved = true; + } else { + // Swap back if no improvement + currentOrder[i + 1] = currentOrder[i]; + currentOrder[i] = temp; + } + } + } + } + + return bestOrder; + } + + int _countCrossings(Graph graph, List nodeOrder) { + if (nodeOrder.length < 3) return 0; + + final nodePositions = {}; + for (var i = 0; i < nodeOrder.length; i++) { + nodePositions[nodeOrder[i]] = i; + } + + var crossings = 0; + final edges = graph.edges; + + // Count crossings between all pairs of edges + for (var i = 0; i < edges.length; i++) { + final edge1 = edges[i]; + final pos1a = nodePositions[edge1.source]!; + final pos1b = nodePositions[edge1.destination]!; + + for (var j = i + 1; j < edges.length; j++) { + final edge2 = edges[j]; + final pos2a = nodePositions[edge2.source]!; + final pos2b = nodePositions[edge2.destination]!; + + // Check if edges cross when nodes are arranged in a circle + if (_edgesCross(pos1a, pos1b, pos2a, pos2b, nodeOrder.length)) { + crossings++; + } + } + } + + return crossings; + } + + bool _edgesCross(int pos1a, int pos1b, int pos2a, int pos2b, int totalNodes) { + // Normalize positions so smaller is first + if (pos1a > pos1b) { + final temp = pos1a; + pos1a = pos1b; + pos1b = temp; + } + if (pos2a > pos2b) { + final temp = pos2a; + pos2a = pos2b; + pos2b = temp; + } + + // Check if one edge's endpoints separate the other edge's endpoints on the circle + return (pos1a < pos2a && pos2a < pos1b && pos1b < pos2b) || + (pos2a < pos1a && pos1a < pos2b && pos2b < pos1b); + } + + Size _layoutNodes(Graph graph) { + // Calculate bounds for auto-sizing + var width = 400.0; + var height = 400.0; + + if (_radius <= 0) { + _radius = 0.35 * max(width, height); + } + + final centerX = width / 2; + final centerY = height / 2; + + // Position nodes in circle + for (var i = 0; i < nodeOrderedList.length; i++) { + final node = nodeOrderedList[i]; + final angle = (2 * pi * i) / nodeOrderedList.length; + + final posX = cos(angle) * _radius + centerX; + final posY = sin(angle) * _radius + centerY; + + node.position = Offset(posX, posY); + } + + // Calculate actual bounds based on positioned nodes + final bounds = graph.calculateGraphBounds(); + return Size(bounds.width + 40, bounds.height + 40); // Add some padding + } + + + void _shiftCoordinates(Graph graph, double shiftX, double shiftY) { + for (final node in graph.nodes) { + node.position = Offset(node.x + shiftX, node.y + shiftY); + } + } + + @override + void init(Graph? graph) { + // Implementation can be added if needed + } + + @override + void setDimensions(double width, double height) { + // Implementation can be added if needed + } + + + @override + EdgeRenderer? renderer; +} \ No newline at end of file diff --git a/lib/tree/RadialTreeLayoutAlgorithm.dart b/lib/tree/RadialTreeLayoutAlgorithm.dart new file mode 100644 index 0000000..9a69c8e --- /dev/null +++ b/lib/tree/RadialTreeLayoutAlgorithm.dart @@ -0,0 +1,302 @@ +part of graphview; + +class TreeLayoutNodeData { + Rectangle? bounds; + int depth = 0; + bool visited = false; + List successorNodes = []; + Node? parent; + + TreeLayoutNodeData(); +} + +class RadialTreeLayoutAlgorithm extends Algorithm { + late BuchheimWalkerConfiguration config; + final Map nodeData = {}; + final Map baseBounds = {}; + final Map polarLocations = {}; + + RadialTreeLayoutAlgorithm(this.config, EdgeRenderer? renderer) { + this.renderer = renderer ?? ArrowEdgeRenderer(); + } + + @override + Size run(Graph? graph, double shiftX, double shiftY) { + if (graph == null || graph.nodes.isEmpty) { + return Size.zero; + } + + nodeData.clear(); + baseBounds.clear(); + polarLocations.clear(); + + // Handle single node case + if (graph.nodes.length == 1) { + final node = graph.nodes.first; + node.position = Offset(shiftX + 100, shiftY + 100); + return Size(200, 200); + } + + _initializeData(graph); + final roots = _findRoots(graph); + + if (roots.isEmpty) { + final spanningTree = _createSpanningTree(graph); + return _layoutSpanningTree(spanningTree, shiftX, shiftY); + } + + // First, build the tree using regular tree layout + _buildRegularTree(graph, roots); + + // Then convert to radial coordinates + _setRadialLocations(graph); + + // Convert polar to cartesian and position nodes + _putRadialPointsInModel(graph); + + _shiftCoordinates(graph, shiftX, shiftY); + + return graph.calculateGraphSize(); + } + + void _initializeData(Graph graph) { + // Initialize node data + for (final node in graph.nodes) { + nodeData[node] = TreeLayoutNodeData(); + } + + // Build tree structure from edges + for (final edge in graph.edges) { + final source = edge.source; + final target = edge.destination; + + nodeData[source]!.successorNodes.add(target); + nodeData[target]!.parent = source; + } + } + + List _findRoots(Graph graph) { + return graph.nodes.where((node) { + return nodeData[node]!.parent == null && successorsOf(node).isNotEmpty; + }).toList(); + } + + void _buildRegularTree(Graph graph, List roots) { + _calculateSubtreeDimensions(roots); + _positionNodes(roots); + } + + void _calculateSubtreeDimensions(List roots) { + final visited = {}; + + for (final root in roots) { + _calculateWidth(root, visited); + } + + visited.clear(); + for (final root in roots) { + _calculateHeight(root, visited); + } + } + + int _calculateWidth(Node node, Set visited) { + if (!visited.add(node)) return 0; + + final children = successorsOf(node); + if (children.isEmpty) { + final width = max(node.width.toInt(), config.siblingSeparation); + baseBounds[node] = Size(width.toDouble(), 0); + return width; + } + + var totalWidth = 0; + for (var i = 0; i < children.length; i++) { + totalWidth += _calculateWidth(children[i], visited); + if (i < children.length - 1) { + totalWidth += config.siblingSeparation; + } + } + + baseBounds[node] = Size(totalWidth.toDouble(), 0); + return totalWidth; + } + + int _calculateHeight(Node node, Set visited) { + if (!visited.add(node)) return 0; + + final children = successorsOf(node); + if (children.isEmpty) { + final height = max(node.height.toInt(), config.levelSeparation); + final current = baseBounds[node]!; + baseBounds[node] = Size(current.width, height.toDouble()); + return height; + } + + var maxChildHeight = 0; + for (final child in children) { + maxChildHeight = max(maxChildHeight, _calculateHeight(child, visited)); + } + + final totalHeight = maxChildHeight + config.levelSeparation; + final current = baseBounds[node]!; + baseBounds[node] = Size(current.width, totalHeight.toDouble()); + return totalHeight; + } + + void _positionNodes(List roots) { + var currentX = config.siblingSeparation.toDouble(); + + for (final root in roots) { + final rootWidth = baseBounds[root]!.width; + currentX += rootWidth / 2; + + _buildTree(root, currentX, config.levelSeparation.toDouble(), {}); + + currentX += rootWidth / 2 + config.siblingSeparation; + } + } + + void _buildTree(Node node, double x, double y, Set visited) { + if (!visited.add(node)) return; + + node.position = Offset(x, y); + + final children = successorsOf(node); + if (children.isEmpty) return; + + final nextY = y + config.levelSeparation; + final totalWidth = baseBounds[node]!.width; + var childX = x - totalWidth / 2; + + for (final child in children) { + final childWidth = baseBounds[child]!.width; + childX += childWidth / 2; + + _buildTree(child, childX, nextY, visited); + + childX += childWidth / 2 + config.siblingSeparation; + } + } + + void _setRadialLocations(Graph graph) { + final bounds = graph.calculateGraphBounds(); + final maxPoint = bounds.width; + + // Calculate theta step based on maximum x coordinate + final theta = 2 * pi / maxPoint; + final deltaRadius = 1.0; + final offset = _findRoots(graph).length > 1 ? config.levelSeparation.toDouble() : 0.0; + + for (final node in graph.nodes) { + final position = node.position; + + // Convert cartesian tree coordinates to polar coordinates + final polarTheta = position.dx * theta; + final polarRadius = (offset + position.dy - config.levelSeparation) * deltaRadius; + + final polarPoint = PolarPoint.of(polarTheta, polarRadius); + polarLocations[node] = polarPoint; + } + } + + void _putRadialPointsInModel(Graph graph) { + final diameter = _calculateDiameter(); + final center = diameter * 0.5 * 0.5; + + polarLocations.forEach((node, polarPoint) { + final cartesian = polarPoint.toCartesian(); + node.position = Offset(center + cartesian.dx, center + cartesian.dy); + }); + } + + double _calculateDiameter() { + if (polarLocations.isEmpty) return 400.0; + + double maxRadius = 0; + polarLocations.values.forEach((polarPoint) { + maxRadius = max(maxRadius, polarPoint.radius * 2); + }); + + return maxRadius + config.siblingSeparation; + } + + void _shiftCoordinates(Graph graph, double shiftX, double shiftY) { + for (final node in graph.nodes) { + node.position = Offset(node.x + shiftX, node.y + shiftY); + } + } + + Graph _createSpanningTree(Graph graph) { + final visited = {}; + final spanningEdges = []; + + if (graph.nodes.isNotEmpty) { + final startNode = graph.nodes.first; + final queue = [startNode]; + visited.add(startNode); + + while (queue.isNotEmpty) { + final current = queue.removeAt(0); + + for (final edge in graph.edges) { + Node? neighbor; + if (edge.source == current && !visited.contains(edge.destination)) { + neighbor = edge.destination; + spanningEdges.add(edge); + } else if (edge.destination == current && !visited.contains(edge.source)) { + neighbor = edge.source; + spanningEdges.add(Edge(current, edge.source)); + } + + if (neighbor != null && !visited.contains(neighbor)) { + visited.add(neighbor); + queue.add(neighbor); + } + } + } + } + + return Graph()..addEdges(spanningEdges); + } + + Size _layoutSpanningTree(Graph spanningTree, double shiftX, double shiftY) { + nodeData.clear(); + baseBounds.clear(); + polarLocations.clear(); + + _initializeData(spanningTree); + final roots = _findRoots(spanningTree); + + if (roots.isEmpty && spanningTree.nodes.isNotEmpty) { + final fakeRoot = spanningTree.nodes.first; + _buildRegularTree(spanningTree, [fakeRoot]); + } else { + _buildRegularTree(spanningTree, roots); + } + + _setRadialLocations(spanningTree); + _putRadialPointsInModel(spanningTree); + + _shiftCoordinates(spanningTree, shiftX, shiftY); + return spanningTree.calculateGraphSize(); + } + + @override + void init(Graph? graph) { + // Implementation can be added if needed + } + + @override + void setDimensions(double width, double height) { + // Implementation can be added if needed + } + + List successorsOf(Node? node) { + return nodeData[node]!.successorNodes; + } + + + + @override + EdgeRenderer? renderer; +} \ No newline at end of file diff --git a/lib/tree/TidierTreeLayoutAlgorithm.dart b/lib/tree/TidierTreeLayoutAlgorithm.dart new file mode 100644 index 0000000..0d6bc1b --- /dev/null +++ b/lib/tree/TidierTreeLayoutAlgorithm.dart @@ -0,0 +1,489 @@ +part of graphview; + +class TidierTreeNodeData { + int mod = 0; + Node? thread; + int shift = 0; + Node? ancestor; + int x = 0; + int change = 0; + int childCount = 0; + List successorNodes = []; + List predecessorNodes = []; + + TidierTreeNodeData(); +} + +class TidierTreeLayoutAlgorithm extends Algorithm { + late BuchheimWalkerConfiguration config; + final Map nodeData = {}; + final Map baseBounds = {}; + final List heights = []; + late List roots; + Rect bounds = Rect.zero; + late Graph tree; + + TidierTreeLayoutAlgorithm(this.config, EdgeRenderer? renderer) { + this.renderer = renderer ?? TreeEdgeRenderer(config); + } + + bool isVertical() { + var orientation = config.orientation; + return orientation == BuchheimWalkerConfiguration.ORIENTATION_TOP_BOTTOM || + orientation == BuchheimWalkerConfiguration.ORIENTATION_BOTTOM_TOP; + } + + bool needReverseOrder() { + var orientation = config.orientation; + return orientation == BuchheimWalkerConfiguration.ORIENTATION_BOTTOM_TOP || + orientation == BuchheimWalkerConfiguration.ORIENTATION_RIGHT_LEFT; + } + + @override + Size run(Graph? graph, double shiftX, double shiftY) { + if (graph == null || graph.nodes.isEmpty) { + return Size.zero; + } + + _clearMetadata(); + + if (graph.nodes.length == 1) { + final node = graph.nodes.first; + node.position = Offset(shiftX + 100, shiftY + 100); + return Size(200, 200); + } + + _buildTree(graph); + _applyOrientation(graph); + _shiftCoordinates(graph, shiftX, shiftY); + + final size = graph.calculateGraphSize(); + _clearMetadata(); + return size; + } + + void _clearMetadata() { + heights.clear(); + baseBounds.clear(); + bounds = Rect.zero; + } + + void _buildTree(Graph graph) { + nodeData.clear(); + heights.clear(); + + _initializeData(graph); + roots = _findRoots(graph); + + if (roots.isEmpty) { + final spanningTree = _createSpanningTree(graph); + _buildTree(spanningTree); + return; + } + + tree = graph; + + final virtualRoot = roots.length > 1 ? null : roots.first; + + _firstWalk(virtualRoot, null); + _computeMaxHeights(virtualRoot, 0); + _secondWalk( + virtualRoot, virtualRoot != null ? -_nodeData(virtualRoot).x : 0, 0, 0); + + _normalizePositions(graph); + } + + void _initializeData(Graph graph) { + // Initialize node data + for (final node in graph.nodes) { + nodeData[node] = TidierTreeNodeData(); + } + + // Build tree structure from edges + for (final edge in graph.edges) { + final source = edge.source; + final target = edge.destination; + + nodeData[source]?.successorNodes.add(target); + nodeData[target]?.predecessorNodes.add(source); + } + } + + List _findRoots(Graph graph) { + final incomingCounts = {}; + for (final node in graph.nodes) { + incomingCounts[node] = 0; + } + + for (final edge in graph.edges) { + incomingCounts[edge.destination] = + (incomingCounts[edge.destination] ?? 0) + 1; + } + + return graph.nodes.where((node) => incomingCounts[node] == 0).toList(); + } + + TidierTreeNodeData _nodeData(Node? v) { + if (v == null) return TidierTreeNodeData(); + return nodeData.putIfAbsent(v, () => TidierTreeNodeData()); + } + + void _firstWalk(Node? v, Node? leftSibling) { + if (successorsOf(v).isEmpty) { + if (leftSibling != null) { + _nodeData(v).x = + _nodeData(leftSibling).x + _getDistance(v, leftSibling, true); + } + } else { + final children = successorsOf(v); + var defaultAncestor = children.isNotEmpty ? children.first : null; + Node? previousChild; + + for (final child in children) { + _firstWalk(child, previousChild); + defaultAncestor = _apportion(child, defaultAncestor, previousChild, v); + previousChild = child; + } + + _shift(v); + + final firstChild = children.isNotEmpty ? children.first : null; + final lastChild = children.isNotEmpty ? children.last : null; + + if (firstChild != null && lastChild != null) { + final midpoint = + (_nodeData(firstChild).x + _nodeData(lastChild).x) ~/ 2; + + if (leftSibling != null) { + _nodeData(v).x = + _nodeData(leftSibling).x + _getDistance(v, leftSibling, true); + _nodeData(v).mod = _nodeData(v).x - midpoint; + } else { + _nodeData(v).x = midpoint; + } + } + } + } + + void _secondWalk(Node? v, int m, int depth, int yOffset) { + if (v == null) { + // Handle multiple roots with subtree separation + var rootOffset = 0; + for (var i = 0; i < roots.length; i++) { + _secondWalk(roots[i], m + rootOffset, depth, yOffset); + if (i < roots.length - 1) { + rootOffset += config.subtreeSeparation; + } + } + return; + } + + final levelHeight = + depth < heights.length ? heights[depth] : config.levelSeparation; + final x = _nodeData(v).x + m; + final y = yOffset + levelHeight ~/ 2; + + v.position = Offset(x.toDouble(), y.toDouble()); + _updateBounds(v, x, y); + + final children = successorsOf(v); + if (children.isNotEmpty) { + final newYOffset = yOffset + levelHeight + config.levelSeparation; + for (final child in children) { + _secondWalk(child, m + _nodeData(v).mod, depth + 1, newYOffset); + } + } + } + + void _updateBounds(Node node, int centerX, int centerY) { + final width = node.width.toInt(); + final height = node.height.toInt(); + final left = centerX - width ~/ 2; + final right = centerX + width ~/ 2; + final top = centerY - height ~/ 2; + final bottom = centerY + height ~/ 2; + + final nodeBounds = Rect.fromLTRB( + left.toDouble(), top.toDouble(), right.toDouble(), bottom.toDouble()); + bounds = + bounds == Rect.zero ? nodeBounds : bounds.expandToInclude(nodeBounds); + } + + void _computeMaxHeights(Node? node, int depth) { + if (node == null) { + for (final root in roots) { + _computeMaxHeights(root, depth); + } + return; + } + + while (heights.length <= depth) { + heights.add(0); + } + + final nodeHeight = isVertical() + ? max(node.height.toInt(), config.levelSeparation) + : max(node.width.toInt(), config.levelSeparation); + heights[depth] = max(heights[depth], nodeHeight); + + for (final child in successorsOf(node)) { + _computeMaxHeights(child, depth + 1); + } + } + + Node? _leftChild(Node? v) { + final children = successorsOf(v); + return children.isNotEmpty ? children.first : _nodeData(v).thread; + } + + Node? _rightChild(Node? v) { + final children = successorsOf(v); + return children.isNotEmpty ? children.last : _nodeData(v).thread; + } + + int _getDistance(Node? v, Node? w, bool isSibling) { + if (v == null || w == null) return config.siblingSeparation; + + // Use appropriate separation based on relationship + final separation = + isSibling ? config.siblingSeparation : config.subtreeSeparation; + + // Consider node sizes in the calculation + final vSize = isVertical() ? v.width.toInt() : v.height.toInt(); + final wSize = isVertical() ? w.width.toInt() : w.height.toInt(); + + return (vSize + wSize) ~/ 2 + separation; + } + + Node? _apportion( + Node? v, Node? defaultAncestor, Node? leftSibling, Node? parentOfV) { + if (leftSibling == null) return defaultAncestor; + + var vor = v; + var vir = v; + Node? vil = leftSibling; + var vol = successorsOf(parentOfV).isNotEmpty + ? successorsOf(parentOfV).first + : null; + + var innerRight = _nodeData(vir).mod; + var outerRight = _nodeData(vor).mod; + var innerLeft = _nodeData(vil).mod; + var outerLeft = _nodeData(vol).mod; + + var nextRightOfVil = _rightChild(vil); + var nextLeftOfVir = _leftChild(vir); + + while (nextRightOfVil != null && nextLeftOfVir != null) { + vil = nextRightOfVil; + vir = nextLeftOfVir; + vol = _leftChild(vol); + vor = _rightChild(vor); + + if (vor != null) { + _nodeData(vor).ancestor = v; + } + + final shift = (_nodeData(vil).x + innerLeft) - + (_nodeData(vir).x + innerRight) + + _getDistance(vil, vir, true); + + if (shift > 0) { + _moveSubtree( + _ancestor(vil, parentOfV, defaultAncestor), v, parentOfV, shift); + innerRight += shift; + outerRight += shift; + } + + innerLeft += _nodeData(vil).mod; + innerRight += _nodeData(vir).mod; + outerLeft += _nodeData(vol).mod; + outerRight += _nodeData(vor).mod; + + nextRightOfVil = _rightChild(vil); + nextLeftOfVir = _leftChild(vir); + } + + if (nextRightOfVil != null && _rightChild(vor) == null) { + _nodeData(vor).thread = nextRightOfVil; + _nodeData(vor).mod += innerLeft - outerRight; + } + + if (nextLeftOfVir != null && _leftChild(vol) == null) { + _nodeData(vol).thread = nextLeftOfVir; + _nodeData(vol).mod += innerRight - outerLeft; + defaultAncestor = v; + } + + return defaultAncestor; + } + + Node? _ancestor(Node? vil, Node? parentOfV, Node? defaultAncestor) { + final ancestor = _nodeData(vil).ancestor ?? vil; + final predecessors = predecessorsOf(ancestor!); + + if (predecessors.contains(parentOfV)) { + return ancestor; + } + return defaultAncestor; + } + + void _moveSubtree( + Node? leftNode, Node? rightNode, Node? parentNode, int shift) { + if (leftNode == null || rightNode == null) return; + + final subtreeCount = _childPosition(rightNode, parentNode) - + _childPosition(leftNode, parentNode); + + if (subtreeCount > 0) { + final rightData = _nodeData(rightNode); + final leftData = _nodeData(leftNode); + + rightData.change -= shift ~/ subtreeCount; + rightData.shift += shift; + leftData.change += shift ~/ subtreeCount; + rightData.x += shift; + rightData.mod += shift; + } + } + + int _childPosition(Node? node, Node? parentNode) { + if (parentNode == null) { + return roots.indexOf(node!) + 1; + } + + if (_nodeData(node).childCount != 0) { + return _nodeData(node).childCount; + } + + final children = successorsOf(parentNode); + for (var i = 0; i < children.length; i++) { + _nodeData(children[i]).childCount = i + 1; + } + + return _nodeData(node).childCount; + } + + void _shift(Node? v) { + final children = successorsOf(v); + + var shift = 0; + var change = 0; + + for (final child in children.reversed) { + final childData = _nodeData(child); + childData.x += shift; + childData.mod += shift; + change += childData.change; + shift += childData.shift + change; + } + } + + void _normalizePositions(Graph graph) { + final graphBounds = graph.calculateGraphBounds(); + final xOffset = config.subtreeSeparation - graphBounds.left; + final yOffset = config.levelSeparation - graphBounds.top; + + for (final node in graph.nodes) { + node.position = Offset( + node.x + xOffset, + node.y + yOffset, + ); + } + } + + void _applyOrientation(Graph graph) { + if (config.orientation == + BuchheimWalkerConfiguration.ORIENTATION_TOP_BOTTOM) { + return; + } + + final bounds = graph.calculateGraphBounds(); + final centerX = bounds.left + bounds.width / 2; + final centerY = bounds.top + bounds.height / 2; + + for (final node in graph.nodes) { + final x = node.x - centerX; + final y = node.y - centerY; + Offset newPosition; + + switch (config.orientation) { + case BuchheimWalkerConfiguration.ORIENTATION_BOTTOM_TOP: + newPosition = Offset(x + centerX, centerY - y); + break; + case BuchheimWalkerConfiguration.ORIENTATION_LEFT_RIGHT: + newPosition = Offset(-y + centerX, x + centerY); + break; + case BuchheimWalkerConfiguration.ORIENTATION_RIGHT_LEFT: + newPosition = Offset(y + centerX, -x + centerY); + break; + default: + newPosition = node.position; + break; + } + + node.position = newPosition; + } + } + + void _shiftCoordinates(Graph graph, double shiftX, double shiftY) { + for (final node in graph.nodes) { + node.position = Offset(node.x + shiftX, node.y + shiftY); + } + } + + Graph _createSpanningTree(Graph graph) { + final visited = {}; + final spanningEdges = []; + + if (graph.nodes.isNotEmpty) { + final startNode = graph.nodes.first; + final queue = [startNode]; + visited.add(startNode); + + while (queue.isNotEmpty) { + final current = queue.removeAt(0); + + for (final edge in graph.edges) { + Node? neighbor; + if (edge.source == current && !visited.contains(edge.destination)) { + neighbor = edge.destination; + spanningEdges.add(edge); + } else if (edge.destination == current && + !visited.contains(edge.source)) { + neighbor = edge.source; + spanningEdges.add(Edge(current, edge.source)); + } + + if (neighbor != null && !visited.contains(neighbor)) { + visited.add(neighbor); + queue.add(neighbor); + } + } + } + } + + return Graph()..addEdges(spanningEdges); + } + + List successorsOf(Node? v) { + if (v == null) return roots; + var nodes = nodeData[v]!.successorNodes; + return nodes; + } + + List predecessorsOf(Node v) { + if (roots.contains(v)) return []; + + return nodeData[v]!.predecessorNodes; + } + + @override + void init(Graph? graph) {} + + @override + void setDimensions(double width, double height) {} + + @override + EdgeRenderer? renderer; +} diff --git a/lib/tree/TreeEdgeRenderer.dart b/lib/tree/TreeEdgeRenderer.dart index d6ae38d..a5bfbc1 100644 --- a/lib/tree/TreeEdgeRenderer.dart +++ b/lib/tree/TreeEdgeRenderer.dart @@ -9,84 +9,209 @@ class TreeEdgeRenderer extends EdgeRenderer { @override void render(Canvas canvas, Graph graph, Paint paint) { - var levelSeparationHalf = configuration.levelSeparation * 0.5; - - graph.nodes.forEach((node) { - var children = graph.successorsOf(node); - - children.forEach((child) { - var edge = graph.getEdgeBetween(node, child); - var edgePaint = (edge?.paint ?? paint)..style = PaintingStyle.stroke; - final parentOffset = getNodePosition(node); - final childOffset = getNodePosition(child); - - final parentCenterX = parentOffset.dx + node.width * 0.5; - final parentCenterY = parentOffset.dy + node.height * 0.5; - final childCenterX = childOffset.dx + child.width * 0.5; - final childCenterY = childOffset.dy + child.height * 0.5; - - linePath.reset(); - - switch (configuration.orientation) { - case BuchheimWalkerConfiguration.ORIENTATION_TOP_BOTTOM: - _drawLShapedPath( - childCenterX, - childOffset.dy, - childCenterX, - childOffset.dy - levelSeparationHalf, - parentCenterX, - childOffset.dy - levelSeparationHalf, - parentCenterX, - parentOffset.dy + node.height); - break; - case BuchheimWalkerConfiguration.ORIENTATION_BOTTOM_TOP: - _drawLShapedPath( - childCenterX, - childOffset.dy + child.height, - childCenterX, - childOffset.dy + child.height + levelSeparationHalf, - parentCenterX, - childOffset.dy + child.height + levelSeparationHalf, - parentCenterX, - parentOffset.dy + node.height); - break; - - case BuchheimWalkerConfiguration.ORIENTATION_LEFT_RIGHT: - _drawLShapedPath( - childOffset.dx, - childCenterY, - childOffset.dx - levelSeparationHalf, - childCenterY, - childOffset.dx - levelSeparationHalf, - parentCenterY, - parentOffset.dx + node.width, - parentCenterY); - break; - - case BuchheimWalkerConfiguration.ORIENTATION_RIGHT_LEFT: - _drawLShapedPath( - childOffset.dx + child.width, - childCenterY, - childOffset.dx + child.width + levelSeparationHalf, - childCenterY, - childOffset.dx + child.width + levelSeparationHalf, - parentCenterY, - parentOffset.dx + node.width, - parentCenterY); - break; - } - canvas.drawPath(linePath, edgePaint); - }); + graph.edges.forEach((edge) { + final edgePaint = (edge.paint ?? paint)..style = PaintingStyle.stroke; + renderEdge(canvas, edge, edgePaint); }); } - void _drawLShapedPath(double x1, double y1, double x2, double y2, double x3, - double y3, double x4, double y4) { - linePath - ..moveTo(x1, y1) - ..lineTo(x2, y2) - ..lineTo(x3, y3) - ..moveTo(x3, y3) - ..lineTo(x4, y4); + void renderEdge(Canvas canvas, Edge edge, Paint edgePaint) { + var node = edge.source; + var child = edge.destination; + + final parentPos = getNodePosition(node); + final childPos = getNodePosition(child); + + final orientation = getEffectiveOrientation(node, child); + + linePath.reset(); + buildEdgePath(node, child, parentPos, childPos, orientation); + + // Check if the destination node has a specific line type + final lineType = child.lineType; + + if (lineType != LineType.Default) { + // For styled lines, we need to draw path segments with the appropriate style + _drawStyledPath(canvas, linePath, edgePaint, lineType); + } else { + canvas.drawPath(linePath, edgePaint); + } + } + + /// Draws a path with the specified line type by converting it to line segments + void _drawStyledPath(Canvas canvas, Path path, Paint paint, LineType lineType) { + // Extract path points for styled rendering + final points = _extractPathPoints(path); + + // Draw each segment with the appropriate style + for (var i = 0; i < points.length - 1; i++) { + drawStyledLine( + canvas, + points[i], + points[i + 1], + paint, + lineType: lineType, + ); + } + } + + /// Extracts key points from a path for segment drawing + List _extractPathPoints(Path path) { + // This is a simplified extraction that works for the L-shaped and curved paths + // For more complex paths, you might need a more sophisticated approach + final points = []; + final metrics = path.computeMetrics(); + + for (var metric in metrics) { + final length = metric.length; + const sampleDistance = 10.0; // Sample every 10 pixels + var distance = 0.0; + + while (distance <= length) { + final tangent = metric.getTangentForOffset(distance); + if (tangent != null) { + points.add(tangent.position); + } + distance += sampleDistance; + } + + // Add the final point + final finalTangent = metric.getTangentForOffset(length); + if (finalTangent != null) { + points.add(finalTangent.position); + } + } + + return points; + } + + int getEffectiveOrientation(Node node, Node child) { + return configuration.orientation; + } + + /// Builds the path for the edge based on orientation + void buildEdgePath(Node node, Node child, Offset parentPos, Offset childPos, int orientation) { + final parentCenterX = parentPos.dx + node.width * 0.5; + final parentCenterY = parentPos.dy + node.height * 0.5; + final childCenterX = childPos.dx + child.width * 0.5; + final childCenterY = childPos.dy + child.height * 0.5; + + if (parentCenterY == childCenterY && parentCenterX == childCenterX) return; + + switch (orientation) { + case BuchheimWalkerConfiguration.ORIENTATION_TOP_BOTTOM: + buildTopBottomPath(node, child, parentPos, childPos, parentCenterX, parentCenterY, childCenterX, childCenterY); + break; + + case BuchheimWalkerConfiguration.ORIENTATION_BOTTOM_TOP: + buildBottomTopPath(node, child, parentPos, childPos, parentCenterX, parentCenterY, childCenterX, childCenterY); + break; + + case BuchheimWalkerConfiguration.ORIENTATION_LEFT_RIGHT: + buildLeftRightPath(node, child, parentPos, childPos, parentCenterX, parentCenterY, childCenterX, childCenterY); + break; + + case BuchheimWalkerConfiguration.ORIENTATION_RIGHT_LEFT: + buildRightLeftPath(node, child, parentPos, childPos, parentCenterX, parentCenterY, childCenterX, childCenterY); + break; + } + } + + /// Builds path for top-bottom orientation + void buildTopBottomPath(Node node, Node child, Offset parentPos, Offset childPos, + double parentCenterX, double parentCenterY, double childCenterX, double childCenterY) { + final parentBottomY = parentPos.dy + node.height * 0.5; + final childTopY = childPos.dy + child.height * 0.5; + final midY = (parentBottomY + childTopY) * 0.5; + + if (configuration.useCurvedConnections) { + // Curved connection + linePath + ..moveTo(childCenterX, childTopY) + ..cubicTo( + childCenterX, midY, + parentCenterX, midY, + parentCenterX, parentBottomY, + ); + } else { + // L-shaped connection + linePath + ..moveTo(parentCenterX, parentBottomY) + ..lineTo(parentCenterX, midY) + ..lineTo(childCenterX, midY) + ..lineTo(childCenterX, childTopY); + } + } + + /// Builds path for bottom-top orientation + void buildBottomTopPath(Node node, Node child, Offset parentPos, Offset childPos, + double parentCenterX, double parentCenterY, double childCenterX, double childCenterY) { + final parentTopY = parentPos.dy + node.height * 0.5; + final childBottomY = childPos.dy + child.height * 0.5; + final midY = (parentTopY + childBottomY) * 0.5; + + if (configuration.useCurvedConnections) { + linePath + ..moveTo(childCenterX, childBottomY) + ..cubicTo( + childCenterX, midY, + parentCenterX, midY, + parentCenterX, parentTopY, + ); + } else { + linePath + ..moveTo(parentCenterX, parentTopY) + ..lineTo(parentCenterX, midY) + ..lineTo(childCenterX, midY) + ..lineTo(childCenterX, childBottomY); + } + } + + /// Builds path for left-right orientation + void buildLeftRightPath(Node node, Node child, Offset parentPos, Offset childPos, + double parentCenterX, double parentCenterY, double childCenterX, double childCenterY) { + final parentRightX = parentPos.dx + node.width * 0.5; + final childLeftX = childPos.dx + child.width * 0.5; + final midX = (parentRightX + childLeftX) * 0.5; + + if (configuration.useCurvedConnections) { + linePath + ..moveTo(childLeftX, childCenterY) + ..cubicTo( + midX, childCenterY, + midX, parentCenterY, + parentRightX, parentCenterY, + ); + } else { + linePath + ..moveTo(parentRightX, parentCenterY) + ..lineTo(midX, parentCenterY) + ..lineTo(midX, childCenterY) + ..lineTo(childLeftX, childCenterY); + } + } + + /// Builds path for right-left orientation + void buildRightLeftPath(Node node, Node child, Offset parentPos, Offset childPos, + double parentCenterX, double parentCenterY, double childCenterX, double childCenterY) { + final parentLeftX = parentPos.dx + node.width * 0.5; + final childRightX = childPos.dx + child.width * 0.5; + final midX = (parentLeftX + childRightX) * 0.5; + + if (configuration.useCurvedConnections) { + linePath + ..moveTo(childRightX, childCenterY) + ..cubicTo( + midX, childCenterY, + midX, parentCenterY, + parentLeftX, parentCenterY, + ); + } else { + linePath + ..moveTo(parentLeftX, parentCenterY) + ..lineTo(midX, parentCenterY) + ..lineTo(midX, childCenterY) + ..lineTo(childRightX, childCenterY); + } } -} +} \ No newline at end of file diff --git a/pubspec.lock b/pubspec.lock index 068ff17..8331746 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,51 +5,58 @@ packages: dependency: transitive description: name: async - url: "https://pub.dartlang.org" + sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + url: "https://pub.dev" source: hosted - version: "2.8.1" + version: "2.13.0" boolean_selector: dependency: transitive description: name: boolean_selector - url: "https://pub.dartlang.org" + sha256: "5bbf32bc9e518d41ec49718e2931cd4527292c9b0c6d2dffcf7fe6b9a8a8cf72" + url: "https://pub.dev" source: hosted version: "2.1.0" characters: dependency: transitive description: name: characters - url: "https://pub.dartlang.org" + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 + url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.4.0" charcode: dependency: transitive description: name: charcode - url: "https://pub.dartlang.org" + sha256: fb98c0f6d12c920a02ee2d998da788bca066ca5f148492b7085ee23372b12306 + url: "https://pub.dev" source: hosted version: "1.3.1" clock: dependency: transitive description: name: clock - url: "https://pub.dartlang.org" + sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b + url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.2" collection: dependency: "direct main" description: name: collection - url: "https://pub.dartlang.org" + sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" + url: "https://pub.dev" source: hosted - version: "1.15.0" + version: "1.19.1" fake_async: dependency: transitive description: name: fake_async - url: "https://pub.dartlang.org" + sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" + url: "https://pub.dev" source: hosted - version: "1.2.0" + version: "1.3.3" flutter: dependency: "direct main" description: flutter @@ -60,88 +67,131 @@ packages: description: flutter source: sdk version: "0.0.0" + leak_tracker: + dependency: transitive + description: + name: leak_tracker + sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" + url: "https://pub.dev" + source: hosted + version: "11.0.2" + leak_tracker_flutter_testing: + dependency: transitive + description: + name: leak_tracker_flutter_testing + sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" + url: "https://pub.dev" + source: hosted + version: "3.0.10" + leak_tracker_testing: + dependency: transitive + description: + name: leak_tracker_testing + sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" + url: "https://pub.dev" + source: hosted + version: "3.0.2" matcher: dependency: transitive description: name: matcher - url: "https://pub.dartlang.org" + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 + url: "https://pub.dev" + source: hosted + version: "0.12.17" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec + url: "https://pub.dev" source: hosted - version: "0.12.10" + version: "0.11.1" meta: dependency: transitive description: name: meta - url: "https://pub.dartlang.org" + sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + url: "https://pub.dev" source: hosted - version: "1.7.0" + version: "1.16.0" path: dependency: transitive description: name: path - url: "https://pub.dartlang.org" + sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" + url: "https://pub.dev" source: hosted - version: "1.8.0" + version: "1.9.1" sky_engine: dependency: transitive description: flutter source: sdk - version: "0.0.99" + version: "0.0.0" source_span: dependency: transitive description: name: source_span - url: "https://pub.dartlang.org" + sha256: d5f89a9e52b36240a80282b3dc0667dd36e53459717bb17b8fb102d30496606a + url: "https://pub.dev" source: hosted version: "1.8.1" stack_trace: dependency: transitive description: name: stack_trace - url: "https://pub.dartlang.org" + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" + url: "https://pub.dev" source: hosted - version: "1.10.0" + version: "1.12.1" stream_channel: dependency: transitive description: name: stream_channel - url: "https://pub.dartlang.org" + sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" + url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "2.1.4" string_scanner: dependency: transitive description: name: string_scanner - url: "https://pub.dartlang.org" + sha256: dd11571b8a03f7cadcf91ec26a77e02bfbd6bbba2a512924d3116646b4198fc4 + url: "https://pub.dev" source: hosted version: "1.1.0" term_glyph: dependency: transitive description: name: term_glyph - url: "https://pub.dartlang.org" + sha256: a88162591b02c1f3a3db3af8ce1ea2b374bd75a7bb8d5e353bcfbdc79d719830 + url: "https://pub.dev" source: hosted version: "1.2.0" test_api: dependency: transitive description: name: test_api - url: "https://pub.dartlang.org" + sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + url: "https://pub.dev" source: hosted - version: "0.4.2" - typed_data: + version: "0.7.6" + vector_math: dependency: transitive description: - name: typed_data - url: "https://pub.dartlang.org" + name: vector_math + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b + url: "https://pub.dev" source: hosted - version: "1.3.0" - vector_math: + version: "2.2.0" + vm_service: dependency: transitive description: - name: vector_math - url: "https://pub.dartlang.org" + name: vm_service + sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + url: "https://pub.dev" source: hosted - version: "2.1.0" + version: "15.0.2" sdks: - dart: ">=2.12.0 <3.0.0" - flutter: ">=1.17.0" + dart: ">=3.8.0-0 <4.0.0" + flutter: ">=3.18.0-18.0.pre.54" diff --git a/pubspec.yaml b/pubspec.yaml index 22584a8..d527eb8 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,6 +1,6 @@ name: graphview description: GraphView is used to display data in graph structures. It can display Tree layout, Directed and Layered graph. Useful for Family Tree, Hierarchy View. -version: 1.2.0 +version: 1.5.0 homepage: https://github.com/nabil6391/graphview environment: diff --git a/test/algorithm_performance_test.dart b/test/algorithm_performance_test.dart new file mode 100644 index 0000000..936847b --- /dev/null +++ b/test/algorithm_performance_test.dart @@ -0,0 +1,64 @@ +import 'dart:ui'; +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:graphview/GraphView.dart'; + +const itemHeight = 100.0; +const itemWidth = 100.0; +const runs = 5; + +void main() { + Graph _createGraph(int n) { + final graph = Graph(); + final nodes = List.generate(n, (i) => Node.Id(i + 1)); + for (var i = 0; i < n - 1; i++) { + final children = (i < n / 3) ? 3 : 2; + for (var j = 1; j <= children && i * children + j < n; j++) { + graph.addEdge(nodes[i], nodes[i * children + j]); + } + } + for (var i = 0; i < graph.nodeCount(); i++) { + graph.getNodeAtPosition(i).size = const Size(itemWidth, itemHeight); + } + return graph; + } + + test('Algorithm performance', () { + final algorithms = { + 'Buchheim': BuchheimWalkerAlgorithm(BuchheimWalkerConfiguration(), null), + 'Balloon': BalloonLayoutAlgorithm(BuchheimWalkerConfiguration(), null), + 'RadialTree': RadialTreeLayoutAlgorithm(BuchheimWalkerConfiguration(), null), + 'TidierTree': TidierTreeLayoutAlgorithm(BuchheimWalkerConfiguration(), null), + 'Eiglsperger': EiglspergerAlgorithm(SugiyamaConfiguration()), + 'Sugiyama': SugiyamaAlgorithm(SugiyamaConfiguration()), + 'Circle': CircleLayoutAlgorithm(CircleLayoutConfiguration(), null), + }; + + final results = {}; + final graph = _createGraph(1000); + + for (final entry in algorithms.entries) { + final times = []; + for (var i = 0; i < runs; i++) { + final sw = Stopwatch()..start(); + entry.value.run(graph, 0, 0); + times.add(sw.elapsed.inMilliseconds); + } + // results[entry.key] = times.reduce((a, b) => a + b) / times.length; + results[entry.key] = times.reduce((a, b) => a + b).toDouble(); + } + + final sorted = results.entries.toList()..sort((a, b) => a.value.compareTo(b.value)); + + print('\nPerformance Results (${runs} runs avg):'); + for (var i = 0; i < sorted.length; i++) { + print('${(i + 1).toString().padLeft(2)}. ${sorted[i].key.padRight(12)}: ${sorted[i].value.toStringAsFixed(1)} ms'); + } + + for (final result in results.values) { + expect(result < 5000, true); + } + }); +} \ No newline at end of file diff --git a/test/buchheim_walker_algorithm_test.dart b/test/buchheim_walker_algorithm_test.dart index 2970fd4..82980ac 100644 --- a/test/buchheim_walker_algorithm_test.dart +++ b/test/buchheim_walker_algorithm_test.dart @@ -52,8 +52,6 @@ void main() { var size = algorithm.run(graph, 10, 10); var timeTaken = stopwatch.elapsed.inMilliseconds; - print('Timetaken $timeTaken'); - expect(timeTaken < 1000, true); expect(graph.getNodeAtPosition(0).position, Offset(385, 10)); @@ -100,8 +98,24 @@ void main() { ); }); - test('Buchheim Performance for 100 nodes to be less than 2.5s', () { - + test('Buchheim Performance for 1000 nodes to be less than 20ms', () { + Graph _createGraph(int n) { + final graph = Graph(); + final nodes = List.generate(n, (i) => Node.Id(i + 1)); + var currentChild = 1; // Start from node 1 (node 0 is root) + for (var i = 0; i < n && currentChild < n; i++) { + final children = (i < n ~/ 3) ? 3 : 2; + + for (var j = 0; j < children && currentChild < n; j++) { + graph.addEdge(nodes[i], nodes[currentChild]); + currentChild++; + } + } + for (var i = 0; i < graph.nodeCount(); i++) { + graph.getNodeAtPosition(i).size = const Size(itemWidth, itemHeight); + } + return graph; + } final _configuration = BuchheimWalkerConfiguration() ..siblingSeparation = (100) @@ -112,22 +126,18 @@ void main() { var algorithm = BuchheimWalkerAlgorithm( _configuration, TreeEdgeRenderer(_configuration)); + var graph = _createGraph(1000); for (var i = 0; i < graph.nodeCount(); i++) { graph.getNodeAtPosition(i).size = Size(itemWidth, itemHeight); } var stopwatch = Stopwatch()..start(); - - for (var i = 1; i <= 100; i++) { - var size = algorithm.run(graph, 10, 10); - } - - + var size = algorithm.run(graph, 0, 0); var timeTaken = stopwatch.elapsed.inMilliseconds; - print('Timetaken $timeTaken ${graph.nodeCount()}'); + print('Timetaken $timeTaken for ${graph.nodeCount()} nodes'); - expect(timeTaken < 100, true); + expect(timeTaken < 20, true); }); }); } diff --git a/test/graphview_perfomance_test.dart b/test/graphview_perfomance_test.dart new file mode 100644 index 0000000..0b0cc2f --- /dev/null +++ b/test/graphview_perfomance_test.dart @@ -0,0 +1,145 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:graphview/GraphView.dart'; + +void main() { + group('GraphView Performance Tests', () { + + testWidgets('hitTest performance with 1000+ nodes', (WidgetTester tester) async { + final graph = _createLargeGraph(1000); + + final _configuration = BuchheimWalkerConfiguration() + ..siblingSeparation = (100) + ..levelSeparation = (150) + ..subtreeSeparation = (150) + ..orientation = (BuchheimWalkerConfiguration.ORIENTATION_TOP_BOTTOM); + + var algorithm = BuchheimWalkerAlgorithm( + _configuration, TreeEdgeRenderer(_configuration)); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: GraphView.builder( + graph: graph, + algorithm: algorithm, + builder: (Node node) => Container( + width: 50, + height: 50, + decoration: BoxDecoration( + color: Colors.blue, + shape: BoxShape.circle, + ), + child: Center(child: Text(node.key.toString())), + ), + ), + ), + )); + + await tester.pumpAndSettle(); + + final renderBox = tester.renderObject( + find.byType(GraphViewWidget) + ); + + final stopwatch = Stopwatch()..start(); + + // Test multiple hit tests at different positions + for (var i = 0; i < 10; i++) { + final result = BoxHitTestResult(); + renderBox.hitTest(result, position: Offset(i * 10.0, i * 10.0)); + } + + stopwatch.stop(); + final hitTestTime = stopwatch.elapsedMilliseconds; + + print('HitTest time for 1000 nodes (10 tests): ${hitTestTime}ms'); + expect(hitTestTime, lessThan(10), reason: 'HitTest should complete in under 10ms'); + }); + + testWidgets('paint performance with 1000+ nodes', (WidgetTester tester) async { + final graph = _createLargeGraph(1000); + + final _configuration = BuchheimWalkerConfiguration() + ..siblingSeparation = (100) + ..levelSeparation = (150) + ..subtreeSeparation = (150) + ..orientation = (BuchheimWalkerConfiguration.ORIENTATION_TOP_BOTTOM); + + var algorithm = BuchheimWalkerAlgorithm( + _configuration, TreeEdgeRenderer(_configuration)); + + await tester.pumpWidget(MaterialApp( + home: Scaffold( + body: GraphView.builder( + graph: graph, + algorithm: algorithm, + builder: (Node node) => Container( + width: 30, + height: 30, + color: Colors.red, + ), + ), + ), + )); + + final stopwatch = Stopwatch()..start(); + + // Trigger multiple repaints + for (var i = 0; i < 10; i++) { + await tester.pump(); + } + + stopwatch.stop(); + final paintTime = stopwatch.elapsedMilliseconds; + + print('Paint time for 1000 nodes (10 repaints): ${paintTime}ms'); + expect(paintTime, lessThan(50), reason: 'Paint should complete in under 50ms'); + }); + + test('algorithm run performance with 1000+ nodes', () { + final graph = _createLargeGraph(1000); + + final _configuration = BuchheimWalkerConfiguration() + ..siblingSeparation = (100) + ..levelSeparation = (150) + ..subtreeSeparation = (150) + ..orientation = (BuchheimWalkerConfiguration.ORIENTATION_TOP_BOTTOM); + + var algorithm = BuchheimWalkerAlgorithm( + _configuration, TreeEdgeRenderer(_configuration)); + + final stopwatch = Stopwatch()..start(); + + algorithm.run(graph, 0, 0); + + stopwatch.stop(); + final algorithmTime = stopwatch.elapsedMilliseconds; + + print('Algorithm run time for 1000 nodes: ${algorithmTime}ms'); + expect(algorithmTime, lessThan(10), reason: 'Algorithm should complete in under 10 milisecond'); + }); + + }); +} + +/// Creates a large graph with connected nodes for performance testing +Graph _createLargeGraph(int n) { + final graph = Graph(); + // Create nodes + final nodes = List.generate(n, (i) => Node.Id(i + 1)); + +// Generate tree edges using a queue-based approach + int currentChild = 1; // Start from node 1 (node 0 is root) + + for (var i = 0; i < n && currentChild < n; i++) { + final children = (i < n ~/ 3) ? 3 : 2; + + for (var j = 0; j < children && currentChild < n; j++) { + graph.addEdge(nodes[i], nodes[currentChild]); + currentChild++; + } + } + + return graph; +} \ No newline at end of file diff --git a/test/sugiyama_algorithm_test.dart b/test/sugiyama_algorithm_test.dart index d0d27a6..1ed8d58 100644 --- a/test/sugiyama_algorithm_test.dart +++ b/test/sugiyama_algorithm_test.dart @@ -1,6 +1,6 @@ import 'package:flutter/material.dart'; -import 'package:graphview/GraphView.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:graphview/GraphView.dart'; import 'example_trees.dart'; @@ -86,67 +86,7 @@ void main() { graph.addEdge(node1, node22); graph.addEdge(node7, node8); - test('Sugiyama Node positions are correct for Top_Bottom', () { - final _configuration = SugiyamaConfiguration() - ..nodeSeparation = 15 - ..levelSeparation = 15 - ..orientation = SugiyamaConfiguration.ORIENTATION_TOP_BOTTOM; - - var algorithm = SugiyamaAlgorithm(_configuration); - - for (var i = 0; i < graph.nodeCount(); i++) { - graph.getNodeAtPosition(i).size = Size(itemWidth, itemHeight); - } - - var stopwatch = Stopwatch()..start(); - var size = algorithm.run(graph, 10, 10); - var timeTaken = stopwatch.elapsed.inMilliseconds; - - print('Timetaken $timeTaken'); - - expect(timeTaken < 1000, true); - - expect(graph.getNodeAtPosition(0).position, Offset(660.0, 10)); - expect(graph.getNodeAtPosition(6).position, Offset(1045.0, 815.0)); - expect(graph.getNodeAtPosition(13).position, Offset(1045.0, 470.0)); - expect(graph.getNodeAtPosition(22).position, Offset(700, 930.0)); - expect(graph.getNodeUsingId(3).position, Offset(815.0, 125.0)); - expect(graph.getNodeUsingId(4).position, Offset(585.0, 240.0)); - - expect(size, Size(1365.0, 1135.0)); - }); - - test('Sugiyama Node positions correct for LEFT_RIGHT', () { - final _configuration = SugiyamaConfiguration() - ..nodeSeparation = 15 - ..levelSeparation = 15 - ..orientation = SugiyamaConfiguration.ORIENTATION_LEFT_RIGHT; - - var algorithm = SugiyamaAlgorithm(_configuration); - - for (var i = 0; i < graph.nodeCount(); i++) { - graph.getNodeAtPosition(i).size = Size(itemWidth, itemHeight); - } - - var stopwatch = Stopwatch()..start(); - var size = algorithm.run(graph, 10, 10); - var timeTaken = stopwatch.elapsed.inMilliseconds; - - print('Timetaken $timeTaken'); - - expect(timeTaken < 1000, true); - - expect(graph.getNodeAtPosition(0).position, Offset(10, 385.0)); - expect(graph.getNodeAtPosition(6).position, Offset(815.0, 745.0)); - expect(graph.getNodeAtPosition(13).position, Offset(470.0, 745.0)); - expect(graph.getNodeAtPosition(22).position, Offset(930, 500.0)); - expect(graph.getNodeUsingId(3).position, Offset(125.0, 465.0)); - expect(graph.getNodeUsingId(4).position, Offset(240.0, 342.5)); - - expect(size, Size(1135.0, 865.0)); - }); - - test('Sugiyama Performance for unconnected nodes', () { + test('Sugiyama for unconnected nodes', () { final graph = Graph(); graph.addEdge(Node.Id(1), Node.Id(3)); @@ -155,7 +95,8 @@ void main() { final _configuration = SugiyamaConfiguration() ..nodeSeparation = 15 ..levelSeparation = 15 - ..orientation = SugiyamaConfiguration.ORIENTATION_LEFT_RIGHT; + ..orientation = SugiyamaConfiguration.ORIENTATION_LEFT_RIGHT + ..postStraighten = true; var algorithm = SugiyamaAlgorithm(_configuration); @@ -187,7 +128,8 @@ void main() { final _configuration = SugiyamaConfiguration() ..nodeSeparation = 15 ..levelSeparation = 15 - ..orientation = SugiyamaConfiguration.ORIENTATION_LEFT_RIGHT; + ..orientation = SugiyamaConfiguration.ORIENTATION_LEFT_RIGHT + ..postStraighten = true; var algorithm = SugiyamaAlgorithm(_configuration); @@ -220,7 +162,8 @@ void main() { final _configuration = SugiyamaConfiguration() ..nodeSeparation = 15 ..levelSeparation = 15 - ..orientation = SugiyamaConfiguration.ORIENTATION_LEFT_RIGHT; + ..orientation = SugiyamaConfiguration.ORIENTATION_LEFT_RIGHT + ..postStraighten = true; var algorithm = SugiyamaAlgorithm(_configuration); @@ -234,113 +177,762 @@ void main() { expect(timeTaken < 1000, true); - expect(graph.getNodeUsingId(1).position, Offset(10.0, 17.5)); - expect(graph.getNodeUsingId(3).position, Offset(125.0, 10.0)); - expect(graph.getNodeUsingId(9).position, Offset(470.0, 67.5)); + expect(graph.getNodeUsingId(1).position, Offset(125.0, 10.0)); + expect(graph.getNodeUsingId(3).position, Offset(240.0, 10.0)); + expect(graph.getNodeUsingId(9).position, Offset(10.0, 17.5)); expect(size, Size(560.0, 157.5)); }); - }); - test('Sugiyama for a complex graph with 140 nodes', () { - final json = exampleTreeWith140Nodes; + group('Layering Strategy Tests', () { + test('TopDown Strategy - Node Positioning TOP_BOTTOM', () { + final _configuration = SugiyamaConfiguration() + ..nodeSeparation = 15 + ..levelSeparation = 15 + ..layeringStrategy = LayeringStrategy.topDown + ..orientation = SugiyamaConfiguration.ORIENTATION_TOP_BOTTOM + ..postStraighten = true; - final graph = Graph(); + var algorithm = SugiyamaAlgorithm(_configuration); - var edges = json['edges']!; - edges.forEach((element) { - var fromNodeId = element['from']; - var toNodeId = element['to']; - graph.addEdge(Node.Id(fromNodeId), Node.Id(toNodeId)); + for (var i = 0; i < graph.nodeCount(); i++) { + graph.getNodeAtPosition(i).size = Size(itemWidth, itemHeight); + } + + var stopwatch = Stopwatch()..start(); + var size = algorithm.run(graph, 10, 10); + var timeTaken = stopwatch.elapsed.inMilliseconds; + + print( + 'TopDown Strategy TOP_BOTTOM - Time: ${timeTaken}ms, Size: $size'); + + expect(timeTaken < 1000, true); + + expect(graph.getNodeAtPosition(0).position, Offset(660.0, 10)); + expect(graph.getNodeAtPosition(6).position, Offset(1180.0, 815.0)); + expect(graph.getNodeAtPosition(13).position, Offset(1180.0, 470.0)); + expect(graph.getNodeAtPosition(22).position, Offset(790, 930.0)); + expect(graph.getNodeAtPosition(3).position, Offset(660.0, 240.0)); + expect(graph.getNodeAtPosition(4).position, Offset(920.0, 125.0)); + + expect(size, Size(1270.0, 1135.0)); + }); + + test('TopDown Strategy - Node Positioning LEFT_RIGHT', () { + final configuration = SugiyamaConfiguration() + ..nodeSeparation = 15 + ..levelSeparation = 15 + ..layeringStrategy = LayeringStrategy.topDown + ..orientation = SugiyamaConfiguration.ORIENTATION_LEFT_RIGHT + ..postStraighten = true; + + var algorithm = SugiyamaAlgorithm(configuration); + + for (var i = 0; i < graph.nodeCount(); i++) { + graph.getNodeAtPosition(i).size = Size(itemWidth, itemHeight); + } + + var stopwatch = Stopwatch()..start(); + var size = algorithm.run(graph, 10, 10); + var timeTaken = stopwatch.elapsed.inMilliseconds; + + print( + 'TopDown Strategy LEFT_RIGHT - Time: ${timeTaken}ms, Size: $size'); + + expect(timeTaken < 1000, true); + + expect(graph.getNodeAtPosition(0).position, Offset(10, 385.0)); + expect(graph.getNodeAtPosition(6).position, Offset(815.0, 745.0)); + expect(graph.getNodeAtPosition(13).position, Offset(470.0, 745.0)); + expect(graph.getNodeAtPosition(22).position, Offset(930, 500.0)); + expect(graph.getNodeUsingId(3).position, Offset(125.0, 465.0)); + expect(graph.getNodeUsingId(4).position, Offset(240.0, 342.5)); + + expect(size, Size(1135.0, 835.0)); + }); + + test('LongestPath Strategy - Node Positioning', () { + final configuration = SugiyamaConfiguration() + ..nodeSeparation = 15 + ..levelSeparation = 15 + ..layeringStrategy = LayeringStrategy.longestPath + ..orientation = SugiyamaConfiguration.ORIENTATION_TOP_BOTTOM + ..postStraighten = true; + + final algorithm = SugiyamaAlgorithm(configuration); + + for (var i = 0; i < graph.nodeCount(); i++) { + graph.getNodeAtPosition(i).size = Size(itemWidth, itemHeight); + } + + var stopwatch = Stopwatch()..start(); + var size = algorithm.run(graph, 10, 10); + var timeTaken = stopwatch.elapsed.inMilliseconds; + + print('LongestPath Strategy - Time: ${timeTaken}ms, Size: $size'); + + expect(graph.getNodeAtPosition(0).position, Offset(140.0, 10)); + + expect(graph.getNodeAtPosition(6).position, Offset(1505.0, 1045.0)); + expect(graph.getNodeAtPosition(13).position, Offset(1700.0, 815.0)); + expect(graph.getNodeAtPosition(22).position, Offset(985.0, 930.0)); + expect(graph.getNodeAtPosition(3).position, Offset(725.0, 240.0)); + expect(graph.getNodeAtPosition(4).position, Offset(1115.0, 125.0)); + + expect(timeTaken < 1000, true); + expect(size, Size(1660.0, 1135.0)); + }); + + test('CoffmanGraham Strategy - Node Positioning', () { + final configuration = SugiyamaConfiguration() + ..nodeSeparation = 15 + ..levelSeparation = 15 + ..layeringStrategy = LayeringStrategy.coffmanGraham + ..orientation = SugiyamaConfiguration.ORIENTATION_TOP_BOTTOM + ..postStraighten = true; + + final algorithm = SugiyamaAlgorithm(configuration); + + for (var i = 0; i < graph.nodeCount(); i++) { + graph.getNodeAtPosition(i).size = Size(itemWidth, itemHeight); + } + + var stopwatch = Stopwatch()..start(); + var size = algorithm.run(graph, 10, 10); + var timeTaken = stopwatch.elapsed.inMilliseconds; + + print('CoffmanGraham Strategy - Time: ${timeTaken}ms, Size: $size'); + + // Test exact positions + expect(graph.getNodeAtPosition(0).position, Offset(1440.0, 10.0)); + expect(graph.getNodeAtPosition(6).position, Offset(335.0, 1160.0)); + expect(graph.getNodeAtPosition(13).position, Offset(140.0, 470.0)); + expect(graph.getNodeAtPosition(22).position, Offset(400.0, 1045.0)); + expect(graph.getNodeAtPosition(3).position, Offset(1375.0, 240.0)); + expect(graph.getNodeAtPosition(4).position, Offset(1050.0, 125.0)); + + expect(timeTaken < 1000, true); + expect(size, Size(1530.0, 1250.0)); + }); + + test('NetworkSimplex Strategy - Node Positioning', () { + final configuration = SugiyamaConfiguration() + ..nodeSeparation = 15 + ..levelSeparation = 15 + ..layeringStrategy = LayeringStrategy.networkSimplex + ..orientation = SugiyamaConfiguration.ORIENTATION_TOP_BOTTOM + ..postStraighten = true; + + final algorithm = SugiyamaAlgorithm(configuration); + + for (var i = 0; i < graph.nodeCount(); i++) { + graph.getNodeAtPosition(i).size = Size(itemWidth, itemHeight); + } + + var stopwatch = Stopwatch()..start(); + var size = algorithm.run(graph, 10, 10); + var timeTaken = stopwatch.elapsed.inMilliseconds; + + print('NetworkSimplex Strategy - Time: ${timeTaken}ms, Size: $size'); + + // Test exact positions + expect(graph.getNodeAtPosition(0).position, Offset(140.0, 10.0)); + expect(graph.getNodeAtPosition(6).position, Offset(1505.0, 1045.0)); + expect(graph.getNodeAtPosition(13).position, Offset(1700.0, 815.0)); + expect(graph.getNodeAtPosition(22).position, Offset(985.0, 930.0)); + expect(graph.getNodeAtPosition(3).position, Offset(725.0, 240.0)); + expect(graph.getNodeAtPosition(4).position, Offset(1115.0, 125.0)); + + expect(timeTaken < 1000, true); + expect(size, Size(1660.0, 1135.0)); + }); }); - final _configuration = SugiyamaConfiguration() - ..nodeSeparation = 15 - ..levelSeparation = 15 - ..orientation = SugiyamaConfiguration.ORIENTATION_LEFT_RIGHT; + group('Cross Minimization Strategy Tests', () { + test('Simple CrossMinimization - Positioning', () { + final configuration = SugiyamaConfiguration() + ..nodeSeparation = 15 + ..levelSeparation = 15 + ..crossMinimizationStrategy = CrossMinimizationStrategy.simple + ..orientation = SugiyamaConfiguration.ORIENTATION_LEFT_RIGHT + ..postStraighten = true; - final algorithm = SugiyamaAlgorithm(_configuration); + final algorithm = SugiyamaAlgorithm(configuration); - for (var i = 0; i < graph.nodeCount(); i++) { - graph.getNodeAtPosition(i).size = Size(itemWidth, itemHeight); - } + for (var i = 0; i < graph.nodeCount(); i++) { + graph.getNodeAtPosition(i).size = Size(itemWidth, itemHeight); + } - var stopwatch = Stopwatch()..start(); - var size = algorithm.run(graph, 10, 10); - var timeTaken = stopwatch.elapsed.inMilliseconds; + var stopwatch = Stopwatch()..start(); + var size = algorithm.run(graph, 10, 10); + var timeTaken = stopwatch.elapsed.inMilliseconds; - print('Timetaken $timeTaken ${graph.nodeCount()}'); + print('Simple CrossMin - Time: ${timeTaken}ms, Size: $size'); - expect(graph.getNodeAtPosition(0).position, Offset(10.0, 397.5)); - expect(graph.getNodeAtPosition(6).position, Offset(700.0, 10.0)); - expect(graph.getNodeAtPosition(10).position, Offset(1045.0, 125.0)); - expect(graph.getNodeAtPosition(13).position, Offset(1160.0, 240.0)); - expect(graph.getNodeAtPosition(22).position, Offset(1505.0, 722.5)); - expect(graph.getNodeAtPosition(50).position, Offset(1620.0, 2432.5)); - expect(graph.getNodeAtPosition(67).position, Offset(2770, 2950.0)); - expect(graph.getNodeAtPosition(100).position, Offset(930.0, 1620.0)); - expect(graph.getNodeAtPosition(122).position, Offset(1850.0, 3252.5)); - }); + // Test exact positions + expect(graph.getNodeAtPosition(6).position, Offset(815.0, 745.0)); + expect(graph.getNodeAtPosition(13).position, Offset(470.0, 745.0)); + expect(graph.getNodeAtPosition(22).position, Offset(930.0, 500.0)); + expect(graph.getNodeAtPosition(3).position, Offset(240.0, 342.5)); + expect(graph.getNodeAtPosition(4).position, Offset(125.0, 465.0)); + + expect(timeTaken < 1000, true); + expect(size, Size(1135.0, 835.0)); + }); + + test('AccumulatorTree CrossMinimization - Positioning', () { + final configuration = SugiyamaConfiguration() + ..nodeSeparation = 15 + ..levelSeparation = 15 + ..crossMinimizationStrategy = + CrossMinimizationStrategy.accumulatorTree + ..orientation = SugiyamaConfiguration.ORIENTATION_LEFT_RIGHT + ..postStraighten = true; + + final algorithm = SugiyamaAlgorithm(configuration); + + for (var i = 0; i < graph.nodeCount(); i++) { + graph.getNodeAtPosition(i).size = Size(itemWidth, itemHeight); + } + + var stopwatch = Stopwatch()..start(); + var size = algorithm.run(graph, 10, 10); + var timeTaken = stopwatch.elapsed.inMilliseconds; + + print('AccumulatorTree CrossMin - Time: ${timeTaken}ms, Size: $size'); + + // Test exact positions + expect(graph.getNodeAtPosition(0).position, Offset(10.0, 385.0)); + expect(graph.getNodeAtPosition(6).position, Offset(815.0, 715.0)); + expect(graph.getNodeAtPosition(13).position, Offset(470.0, 715.0)); + expect(graph.getNodeAtPosition(22).position, Offset(930.0, 470.0)); + expect(graph.getNodeAtPosition(3).position, Offset(240.0, 342.5)); + expect(graph.getNodeAtPosition(4).position, Offset(125.0, 465.0)); + + expect(timeTaken < 1000, true); + expect(size, Size(1135.0, 805.0)); + }); + }); + + // Test Cycle Removal Strategies + group('Cycle Removal Strategy Tests', () { + final graph = Graph(); + final node1 = Node.Id(1); + final node2 = Node.Id(2); + final node3 = Node.Id(3); + final node4 = Node.Id(4); + final node5 = Node.Id(5); + + // Create a cyclic graph + graph.addEdge(node1, node2); + graph.addEdge(node2, node3); + graph.addEdge(node3, node4); + graph.addEdge(node4, node1); // Creates cycle + graph.addEdge(node2, node5); + + test('DFS Cycle Removal - Positioning', () { + final configuration = SugiyamaConfiguration() + ..nodeSeparation = 15 + ..levelSeparation = 15 + ..cycleRemovalStrategy = CycleRemovalStrategy.dfs + ..orientation = SugiyamaConfiguration.ORIENTATION_TOP_BOTTOM + ..postStraighten = true; + + final algorithm = SugiyamaAlgorithm(configuration); + + for (var i = 0; i < graph.nodeCount(); i++) { + graph.getNodeAtPosition(i).size = Size(itemWidth, itemHeight); + } + + var stopwatch = Stopwatch()..start(); + var size = algorithm.run(graph, 10, 10); + var timeTaken = stopwatch.elapsed.inMilliseconds; + + print('DFS Cycle Removal - Time: ${timeTaken}ms, Size: $size'); + + // Test exact positions - layout should be acyclic + expect(graph.getNodeAtPosition(1).position, Offset(75.0, 125.0)); + expect(graph.getNodeAtPosition(2).position, Offset(10.0, 240.0)); + expect(graph.getNodeAtPosition(3).position, Offset(140.0, 355.0)); + expect(timeTaken < 1000, true); + expect(size, Size(230, 445.0)); + }); + + test('Greedy Cycle Removal - Positioning', () { + final configuration = SugiyamaConfiguration() + ..nodeSeparation = 15 + ..levelSeparation = 15 + ..cycleRemovalStrategy = CycleRemovalStrategy.greedy + ..orientation = SugiyamaConfiguration.ORIENTATION_TOP_BOTTOM + ..postStraighten = true; + + final algorithm = SugiyamaAlgorithm(configuration); - test('Sugiyama child nodes never overlaps', () { - for (final json in exampleTrees) { - final graph = Graph()..inflateWithJson(json); + for (var i = 0; i < graph.nodeCount(); i++) { + graph.getNodeAtPosition(i).size = Size(itemWidth, itemHeight); + } + + var stopwatch = Stopwatch()..start(); + var size = algorithm.run(graph, 10, 10); + var timeTaken = stopwatch.elapsed.inMilliseconds; + + print('Greedy Cycle Removal - Time: ${timeTaken}ms, Size: $size'); + + // Test exact positions - layout should be acyclic + expect(graph.getNodeAtPosition(1).position, Offset(75.0, 240.0)); + expect(graph.getNodeAtPosition(2).position, Offset(140.0, 355.0)); + expect(graph.getNodeAtPosition(3).position, Offset(75.0, 10.0)); + expect(timeTaken < 1000, true); + expect(size, Size(230.0, 445.0)); + }); + }); + + group('Coordinate Assignment Strategy Tests', () { + test('DownRight Coordinate Assignment', () { + final configuration = SugiyamaConfiguration() + ..nodeSeparation = 15 + ..levelSeparation = 15 + ..coordinateAssignment = CoordinateAssignment.DownRight + ..orientation = SugiyamaConfiguration.ORIENTATION_TOP_BOTTOM + ..postStraighten = true; + + final algorithm = SugiyamaAlgorithm(configuration); + + for (var i = 0; i < graph.nodeCount(); i++) { + graph.getNodeAtPosition(i).size = Size(itemWidth, itemHeight); + } + + var stopwatch = Stopwatch()..start(); + var size = algorithm.run(graph, 10, 10); + var timeTaken = stopwatch.elapsed.inMilliseconds; + + print('DownRight Assignment - Time: ${timeTaken}ms, Size: $size'); + + // Test exact positions + expect(graph.getNodeAtPosition(1).position, Offset(790.0, 700.0)); + expect(graph.getNodeAtPosition(2).position, Offset(1050.0, 930.0)); + expect(graph.getNodeAtPosition(3).position, Offset(530.0, 240.0)); + + expect(timeTaken < 1000, true); + expect(size, Size(1790.0, 1135.0)); + }); + + test('DownLeft Coordinate Assignment', () { + final configuration = SugiyamaConfiguration() + ..nodeSeparation = 15 + ..levelSeparation = 15 + ..coordinateAssignment = CoordinateAssignment.DownLeft + ..orientation = SugiyamaConfiguration.ORIENTATION_TOP_BOTTOM + ..postStraighten = true; + + final algorithm = SugiyamaAlgorithm(configuration); + + for (var i = 0; i < graph.nodeCount(); i++) { + graph.getNodeAtPosition(i).size = Size(itemWidth, itemHeight); + } + + var stopwatch = Stopwatch()..start(); + var size = algorithm.run(graph, 10, 10); + var timeTaken = stopwatch.elapsed.inMilliseconds; + + print('DownLeft Assignment - Time: ${timeTaken}ms, Size: $size'); + + // Test exact positions + expect(graph.getNodeAtPosition(0).position, Offset(1310.0, 10.0)); + expect(graph.getNodeAtPosition(6).position, Offset(1180.0, 815.0)); + expect(graph.getNodeAtPosition(22).position, Offset(530.0, 930.0)); + expect(graph.getNodeUsingId(3).position, Offset(1310.0, 125.0)); + expect(graph.getNodeUsingId(4).position, Offset(920.0, 240.0)); + + expect(timeTaken < 1000, true); + expect(size, Size(1530.0, 1135.0)); + }); + + test('Average Coordinate Assignment', () { + final configuration = SugiyamaConfiguration() + ..nodeSeparation = 15 + ..levelSeparation = 15 + ..coordinateAssignment = CoordinateAssignment.Average + ..orientation = SugiyamaConfiguration.ORIENTATION_TOP_BOTTOM + ..postStraighten = true; + + final algorithm = SugiyamaAlgorithm(configuration); + + for (var i = 0; i < graph.nodeCount(); i++) { + graph.getNodeAtPosition(i).size = Size(itemWidth, itemHeight); + } + + var stopwatch = Stopwatch()..start(); + var size = algorithm.run(graph, 10, 10); + var timeTaken = stopwatch.elapsed.inMilliseconds; + + print('Average Assignment - Time: ${timeTaken}ms, Size: $size'); + + // Test exact positions + expect(graph.getNodeAtPosition(0).position, Offset(660.0, 10.0)); + expect(graph.getNodeAtPosition(13).position, Offset(1180.0, 470.0)); + expect(graph.getNodeAtPosition(22).position, Offset(790, 930.0)); + expect(graph.getNodeUsingId(3).position, Offset(920.0, 125.0)); + expect(graph.getNodeUsingId(4).position, Offset(660.0, 240.0)); + + expect(timeTaken < 1000, true); + expect(size, Size(1270.0, 1135.0)); + }); + + test('UpRight Coordinate Assignment', () { + final configuration = SugiyamaConfiguration() + ..nodeSeparation = 15 + ..levelSeparation = 15 + ..coordinateAssignment = CoordinateAssignment.UpRight + ..orientation = SugiyamaConfiguration.ORIENTATION_TOP_BOTTOM + ..postStraighten = true; + + final algorithm = SugiyamaAlgorithm(configuration); + + for (var i = 0; i < graph.nodeCount(); i++) { + graph.getNodeAtPosition(i).size = Size(itemWidth, itemHeight); + } + + var stopwatch = Stopwatch()..start(); + var size = algorithm.run(graph, 10, 10); + var timeTaken = stopwatch.elapsed.inMilliseconds; + + print('UpRight Assignment - Time: ${timeTaken}ms, Size: $size'); + + // Test exact positions + expect(graph.getNodeAtPosition(0).position, Offset(140.0, 10.0)); + expect(graph.getNodeAtPosition(6).position, Offset(1050.0, 815.0)); + expect(graph.getNodeAtPosition(13).position, Offset(1050.0, 470.0)); + expect(graph.getNodeAtPosition(22).position, Offset(400.0, 930.0)); + expect(graph.getNodeAtPosition(3).position, Offset(400.0, 240.0)); + expect(graph.getNodeAtPosition(4).position, Offset(1050.0, 125.0)); + + expect(timeTaken < 1000, true); + expect(size, Size(1140.0, 1135.0)); + }); + + test('UpLeft Coordinate Assignment', () { + final configuration = SugiyamaConfiguration() + ..nodeSeparation = 15 + ..levelSeparation = 15 + ..coordinateAssignment = CoordinateAssignment.UpLeft + ..orientation = SugiyamaConfiguration.ORIENTATION_TOP_BOTTOM + ..postStraighten = true; + + final algorithm = SugiyamaAlgorithm(configuration); + + for (var i = 0; i < graph.nodeCount(); i++) { + graph.getNodeAtPosition(i).size = Size(itemWidth, itemHeight); + } + + var stopwatch = Stopwatch()..start(); + var size = algorithm.run(graph, 10, 10); + var timeTaken = stopwatch.elapsed.inMilliseconds; + + print('UpLeft Assignment - Time: ${timeTaken}ms, Size: $size'); + + // Test exact positions + expect(graph.getNodeAtPosition(0).position, Offset(140.0, 10.0)); + expect(graph.getNodeAtPosition(6).position, Offset(1440.0, 815.0)); + expect(graph.getNodeAtPosition(13).position, Offset(1440.0, 470.0)); + expect(graph.getNodeAtPosition(22).position, Offset(1310.0, 930.0)); + expect(graph.getNodeAtPosition(3).position, Offset(270.0, 240.0)); + expect(graph.getNodeAtPosition(4).position, Offset(1440.0, 125.0)); + + expect(timeTaken < 1000, true); + expect(size, Size(1660.0, 1135.0)); + }); + }); + + // Performance Tests for 140 Node Graph + group('140 Node Graph Performance Tests', () { + test('Layering Strategy Performance Comparison - 140 Nodes', () { + print('\n=== 140 Node Graph - Layering Strategy Performance ==='); + + final strategies = [ + {'strategy': LayeringStrategy.topDown, 'name': 'TopDown'}, + {'strategy': LayeringStrategy.longestPath, 'name': 'LongestPath'}, + {'strategy': LayeringStrategy.coffmanGraham, 'name': 'CoffmanGraham'}, + { + 'strategy': LayeringStrategy.networkSimplex, + 'name': 'NetworkSimplex' + }, + ]; + + for (final strategy in strategies) { + final graph = Graph(); + graph.inflateWithJson(exampleTreeWith140Nodes); + + for (var i = 0; i < graph.nodeCount(); i++) { + graph.getNodeAtPosition(i).size = Size(itemWidth, itemHeight); + } + + final configuration = SugiyamaConfiguration() + ..nodeSeparation = 15 + ..levelSeparation = 15 + ..layeringStrategy = strategy['strategy'] as LayeringStrategy + ..orientation = SugiyamaConfiguration.ORIENTATION_TOP_BOTTOM + ..postStraighten = true; + + final algorithm = SugiyamaAlgorithm(configuration); + + final stopwatch = Stopwatch()..start(); + final size = algorithm.run(graph, 10, 10); + final timeTaken = stopwatch.elapsed.inMilliseconds; + + print( + '${strategy['name']}: ${timeTaken}ms - Layout size: $size - Nodes: ${graph.nodeCount()}'); + + expect(timeTaken < 3000, true, + reason: + '${strategy['name']} should complete within 3 seconds for 140 nodes'); + } + }); + + test('CrossMinimization Strategy Performance - 140 Nodes', () { + print('\n=== 140 Node Graph - Cross Minimization Performance ==='); + + final strategies = [ + {'strategy': CrossMinimizationStrategy.simple, 'name': 'Simple'}, + { + 'strategy': CrossMinimizationStrategy.accumulatorTree, + 'name': 'AccumulatorTree' + }, + ]; + + for (final strategy in strategies) { + final graph = Graph(); + graph.inflateWithJson(exampleTreeWith140Nodes); + + for (var i = 0; i < graph.nodeCount(); i++) { + graph.getNodeAtPosition(i).size = Size(itemWidth, itemHeight); + } + + final configuration = SugiyamaConfiguration() + ..nodeSeparation = 15 + ..levelSeparation = 15 + ..crossMinimizationStrategy = + strategy['strategy'] as CrossMinimizationStrategy + ..orientation = SugiyamaConfiguration.ORIENTATION_LEFT_RIGHT + ..postStraighten = true; + + final algorithm = SugiyamaAlgorithm(configuration); + + final stopwatch = Stopwatch()..start(); + final size = algorithm.run(graph, 10, 10); + final timeTaken = stopwatch.elapsed.inMilliseconds; + + print( + '${strategy['name']}: ${timeTaken}ms - Layout size: $size - Nodes: ${graph.nodeCount()}'); + + expect(timeTaken < 3000, true, + reason: '${strategy['name']} should complete within 3 seconds'); + } + }); + + test('Cycle Removal Strategy Performance - 140 Nodes', () { + print('\n=== 140 Node Graph - Cycle Removal Performance ==='); + + final strategies = [ + {'strategy': CycleRemovalStrategy.dfs, 'name': 'DFS'}, + {'strategy': CycleRemovalStrategy.greedy, 'name': 'Greedy'}, + ]; + + for (final strategy in strategies) { + final graph = Graph(); + graph.inflateWithJson(exampleTreeWith140Nodes); + + for (var i = 0; i < graph.nodeCount(); i++) { + graph.getNodeAtPosition(i).size = Size(itemWidth, itemHeight); + } + + final configuration = SugiyamaConfiguration() + ..nodeSeparation = 15 + ..levelSeparation = 15 + ..cycleRemovalStrategy = + strategy['strategy'] as CycleRemovalStrategy + ..orientation = SugiyamaConfiguration.ORIENTATION_TOP_BOTTOM + ..postStraighten = true; + + final algorithm = SugiyamaAlgorithm(configuration); + + final stopwatch = Stopwatch()..start(); + final size = algorithm.run(graph, 10, 10); + final timeTaken = stopwatch.elapsed.inMilliseconds; + + print( + '${strategy['name']}: ${timeTaken}ms - Layout size: $size - Nodes: ${graph.nodeCount()}'); + + expect(timeTaken < 3000, true, + reason: '${strategy['name']} should complete within 3 seconds'); + } + }); + + test('Coordinate Assignment Performance - 140 Nodes', () { + print('\n=== 140 Node Graph - Coordinate Assignment Performance ==='); + + final strategies = [ + {'strategy': CoordinateAssignment.DownRight, 'name': 'DownRight'}, + {'strategy': CoordinateAssignment.DownLeft, 'name': 'DownLeft'}, + {'strategy': CoordinateAssignment.UpRight, 'name': 'UpRight'}, + {'strategy': CoordinateAssignment.UpLeft, 'name': 'UpLeft'}, + {'strategy': CoordinateAssignment.Average, 'name': 'Average'}, + ]; + + for (final strategy in strategies) { + final graph = Graph(); + graph.inflateWithJson(exampleTreeWith140Nodes); + + for (var i = 0; i < graph.nodeCount(); i++) { + graph.getNodeAtPosition(i).size = Size(itemWidth, itemHeight); + } + + final configuration = SugiyamaConfiguration() + ..nodeSeparation = 15 + ..levelSeparation = 15 + ..coordinateAssignment = + strategy['strategy'] as CoordinateAssignment + ..orientation = SugiyamaConfiguration.ORIENTATION_TOP_BOTTOM + ..postStraighten = true; + + final algorithm = SugiyamaAlgorithm(configuration); + + final stopwatch = Stopwatch()..start(); + final size = algorithm.run(graph, 10, 10); + final timeTaken = stopwatch.elapsed.inMilliseconds; + + print( + '${strategy['name']}: ${timeTaken}ms - Layout size: $size - Nodes: ${graph.nodeCount()}'); + + expect(timeTaken < 3000, true, + reason: '${strategy['name']} should complete within 3 seconds'); + } + }); + }); + + test('PostStraighten Effect on Node Positioning', () { + // Test with PostStraighten ON for (var i = 0; i < graph.nodeCount(); i++) { graph.getNodeAtPosition(i).size = Size(itemWidth, itemHeight); } - var stopwatch = Stopwatch()..start(); + final configurationOn = SugiyamaConfiguration() + ..nodeSeparation = 15 + ..levelSeparation = 15 + ..orientation = SugiyamaConfiguration.ORIENTATION_TOP_BOTTOM + ..postStraighten = false; + + final algorithmOn = SugiyamaAlgorithm(configurationOn); + algorithmOn.run(graph, 10, 10); + + expect(graph.getNodeAtPosition(0).position, Offset(660.0, 10)); + expect(graph.getNodeAtPosition(6).position, Offset(1180.0, 815.0)); + expect(graph.getNodeUsingId(3).position, Offset(920.0, 125.0)); + }); - SugiyamaAlgorithm(SugiyamaConfiguration())..run(graph, 10, 10); + test('Sugiyama for a complex graph with 140 nodes', () { + final json = exampleTreeWith140Nodes; + final graph = Graph(); + + var edges = json['edges']!; + edges.forEach((element) { + var fromNodeId = element['from']; + var toNodeId = element['to']; + graph.addEdge(Node.Id(fromNodeId), Node.Id(toNodeId)); + }); + + final _configuration = SugiyamaConfiguration() + ..nodeSeparation = 15 + ..levelSeparation = 15 + ..orientation = SugiyamaConfiguration.ORIENTATION_LEFT_RIGHT + ..postStraighten = true; + + final algorithm = SugiyamaAlgorithm(_configuration); + + for (var i = 0; i < graph.nodeCount(); i++) { + graph.getNodeAtPosition(i).size = Size(itemWidth, itemHeight); + } + + var stopwatch = Stopwatch()..start(); + var size = algorithm.run(graph, 10, 10); var timeTaken = stopwatch.elapsed.inMilliseconds; print('Timetaken $timeTaken ${graph.nodeCount()}'); - for (var i = 0; i < graph.nodeCount(); i++) { - final currentNode = graph.getNodeAtPosition(i); - for (var j = 0; j < graph.nodeCount(); j++) { - final otherNode = graph.getNodeAtPosition(j); + expect(graph.getNodeAtPosition(0).position, Offset(10.0, 1715.0)); + expect(graph.getNodeAtPosition(6).position, Offset(815.0, 1757.5)); + expect(graph.getNodeAtPosition(10).position, Offset(1160.0, 1872.5)); + expect(graph.getNodeAtPosition(13).position, Offset(1275.0, 2117.5)); + expect(graph.getNodeAtPosition(22).position, Offset(1620.0, 2635.0)); + expect(graph.getNodeAtPosition(50).position, Offset(1505.0, 1232.5)); + expect(graph.getNodeAtPosition(67).position, Offset(2655.0, 1700.0)); + expect(graph.getNodeAtPosition(100).position, Offset(815.0, 412.5)); + expect(graph.getNodeAtPosition(122).position, Offset(1735.0,2060.0)); + }); + + test('Sugiyama child nodes never overlaps', () { + for (final json in exampleTrees) { + final graph = Graph()..inflateWithJson(json); + for (var i = 0; i < graph.nodeCount(); i++) { + graph.getNodeAtPosition(i).size = Size(itemWidth, itemHeight); + } + + var stopwatch = Stopwatch()..start(); + + SugiyamaAlgorithm(SugiyamaConfiguration()..postStraighten = true) + ..run(graph, 10, 10); - if (currentNode.key == otherNode.key) continue; - final currentRect = currentNode.toRect(); - final otherRect = otherNode.toRect(); + var timeTaken = stopwatch.elapsed.inMilliseconds; - final overlaps = currentRect.overlaps(otherRect); - expect(false, overlaps, reason: '$currentNode overlaps $otherNode'); + print('Timetaken $timeTaken ${graph.nodeCount()}'); + + for (var i = 0; i < graph.nodeCount(); i++) { + final currentNode = graph.getNodeAtPosition(i); + for (var j = 0; j < graph.nodeCount(); j++) { + final otherNode = graph.getNodeAtPosition(j); + + if (currentNode.key == otherNode.key) continue; + final currentRect = currentNode.toRect(); + final otherRect = otherNode.toRect(); + + final overlaps = currentRect.overlaps(otherRect); + expect(false, overlaps, reason: '$currentNode overlaps $otherNode'); + } } } - } - }); + }); - test('Sugiyama Performance for 100 nodes to be less than 2.5s', () { - final graph = Graph(); + test('Sugiyama Performance for 100 nodes to be less than 2.5s', () { + final graph = Graph(); - var rows = 100; + var rows = 100; - for (var i = 1; i <= rows; i++) { - for (var j = 1; j <= i; j++) { - graph.addEdge(Node.Id(i), Node.Id(j)); + for (var i = 1; i <= rows; i++) { + for (var j = 1; j <= i; j++) { + graph.addEdge(Node.Id(i), Node.Id(j)); + } } - } - final _configuration = SugiyamaConfiguration() - ..nodeSeparation = 15 - ..levelSeparation = 15 - ..orientation = SugiyamaConfiguration.ORIENTATION_LEFT_RIGHT; + final _configuration = SugiyamaConfiguration() + ..nodeSeparation = 15 + ..levelSeparation = 15 + ..orientation = SugiyamaConfiguration.ORIENTATION_LEFT_RIGHT + ..postStraighten = true; - var algorithm = SugiyamaAlgorithm(_configuration); + var algorithm = SugiyamaAlgorithm(_configuration); - for (var i = 0; i < graph.nodeCount(); i++) { - graph.getNodeAtPosition(i).size = Size(itemWidth, itemHeight); - } + for (var i = 0; i < graph.nodeCount(); i++) { + graph.getNodeAtPosition(i).size = Size(itemWidth, itemHeight); + } - var stopwatch = Stopwatch()..start(); - var size = algorithm.run(graph, 10, 10); - var timeTaken = stopwatch.elapsed.inMilliseconds; + var stopwatch = Stopwatch()..start(); + var size = algorithm.run(graph, 10, 10); + var timeTaken = stopwatch.elapsed.inMilliseconds; - print('Timetaken $timeTaken ${graph.nodeCount()}'); + print('Timetaken $timeTaken ${graph.nodeCount()}'); - expect(timeTaken < 2500, true); + expect(timeTaken < 2500, true); + }); }); }