├── image ├── Graph.png ├── AllExamples.gif ├── TopDownTree.png ├── BottomTopTree.png ├── CircleLayout.gif ├── LayeredGraph.png ├── LeftRightTree.png ├── MindMapLayout.gif ├── RightLeftTree.png ├── RadialTreeLayout.gif ├── TidierTreeLayout.gif ├── BalloonTreeLayout.gif ├── AutoNavigationExample.gif └── NodeExpandCollapseAnimation.gif ├── lib ├── layered │ ├── SugiyamaEdgeData.dart │ ├── SugiyamaNodeData.dart │ ├── SugiyamaConfiguration.dart │ └── SugiyamaEdgeRenderer.dart ├── tree │ ├── BuchheimWalkerNodeData.dart │ ├── BuchheimWalkerConfiguration.dart │ ├── BaloonLayoutAlgorithm.dart │ ├── TreeEdgeRenderer.dart │ ├── CircleLayoutAlgorithm.dart │ └── RadialTreeLayoutAlgorithm.dart ├── Algorithm.dart ├── mindmap │ ├── MindmapEdgeRenderer.dart │ └── MindMapAlgorithm.dart ├── forcedirected │ └── FruchtermanReingoldConfiguration.dart ├── edgerenderer │ ├── EdgeRenderer.dart │ └── ArrowEdgeRenderer.dart └── Graph.dart ├── .metadata ├── pubspec.yaml ├── .github ├── workflows │ └── tests.yml └── FUNDING.yml ├── example ├── .gitignore ├── analysis_options.yaml ├── lib │ ├── decision_tree_screen.dart │ ├── example.dart │ ├── graph_cluster_animated.dart │ ├── force_directed_graphview.dart │ ├── tree_graphview_json.dart │ ├── mutliple_forest_graphview.dart │ ├── large_tree_graphview.dart │ ├── layer_graphview_json.dart │ ├── layer_eiglesperger_graphview.dart │ ├── mindmap_graphview.dart │ ├── tree_graphview.dart │ └── layer_graphview.dart ├── pubspec.yaml └── pubspec.lock ├── LICENSE ├── analysis_options.yaml ├── .gitignore ├── test ├── algorithm_performance_test.dart ├── graph_test.dart ├── graphview_perfomance_test.dart ├── buchheim_walker_algorithm_test.dart └── controller_tests.dart ├── CHANGELOG.md └── pubspec.lock /image/Graph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nabil6391/graphview/HEAD/image/Graph.png -------------------------------------------------------------------------------- /image/AllExamples.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nabil6391/graphview/HEAD/image/AllExamples.gif -------------------------------------------------------------------------------- /image/TopDownTree.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nabil6391/graphview/HEAD/image/TopDownTree.png -------------------------------------------------------------------------------- /image/BottomTopTree.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nabil6391/graphview/HEAD/image/BottomTopTree.png -------------------------------------------------------------------------------- /image/CircleLayout.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nabil6391/graphview/HEAD/image/CircleLayout.gif -------------------------------------------------------------------------------- /image/LayeredGraph.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nabil6391/graphview/HEAD/image/LayeredGraph.png -------------------------------------------------------------------------------- /image/LeftRightTree.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nabil6391/graphview/HEAD/image/LeftRightTree.png -------------------------------------------------------------------------------- /image/MindMapLayout.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nabil6391/graphview/HEAD/image/MindMapLayout.gif -------------------------------------------------------------------------------- /image/RightLeftTree.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nabil6391/graphview/HEAD/image/RightLeftTree.png -------------------------------------------------------------------------------- /image/RadialTreeLayout.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nabil6391/graphview/HEAD/image/RadialTreeLayout.gif -------------------------------------------------------------------------------- /image/TidierTreeLayout.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nabil6391/graphview/HEAD/image/TidierTreeLayout.gif -------------------------------------------------------------------------------- /image/BalloonTreeLayout.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nabil6391/graphview/HEAD/image/BalloonTreeLayout.gif -------------------------------------------------------------------------------- /image/AutoNavigationExample.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nabil6391/graphview/HEAD/image/AutoNavigationExample.gif -------------------------------------------------------------------------------- /image/NodeExpandCollapseAnimation.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nabil6391/graphview/HEAD/image/NodeExpandCollapseAnimation.gif -------------------------------------------------------------------------------- /lib/layered/SugiyamaEdgeData.dart: -------------------------------------------------------------------------------- 1 | part of graphview; 2 | 3 | class SugiyamaEdgeData { 4 | List bendPoints = []; 5 | } 6 | -------------------------------------------------------------------------------- /.metadata: -------------------------------------------------------------------------------- 1 | # This file tracks properties of this Flutter project. 2 | # Used by Flutter tool to assess capabilities and perform upgrades etc. 3 | # 4 | # This file should be version controlled and should not be manually edited. 5 | 6 | version: 7 | revision: 7c6f9dd2396dfe7deb6fd11edc12c10786490083 8 | channel: master 9 | 10 | project_type: package 11 | -------------------------------------------------------------------------------- /lib/tree/BuchheimWalkerNodeData.dart: -------------------------------------------------------------------------------- 1 | part of graphview; 2 | 3 | class BuchheimWalkerNodeData { 4 | Node? ancestor; 5 | Node? thread; 6 | int number = 0; 7 | int depth = 0; 8 | double prelim = 0.toDouble(); 9 | double modifier = 0.toDouble(); 10 | double shift = 0.toDouble(); 11 | double change = 0.toDouble(); 12 | List predecessorNodes = []; 13 | List successorNodes = []; 14 | } 15 | -------------------------------------------------------------------------------- /lib/Algorithm.dart: -------------------------------------------------------------------------------- 1 | part of graphview; 2 | 3 | abstract class Algorithm { 4 | EdgeRenderer? renderer; 5 | 6 | /// Executes the algorithm. 7 | /// @param shiftY Shifts the y-coordinate origin 8 | /// @param shiftX Shifts the x-coordinate origin 9 | /// @return The size of the graph 10 | Size run(Graph? graph, double shiftX, double shiftY); 11 | 12 | void init(Graph? graph); 13 | 14 | void setDimensions(double width, double height); 15 | } 16 | -------------------------------------------------------------------------------- /pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: graphview 2 | 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. 3 | version: 1.5.1 4 | homepage: https://github.com/nabil6391/graphview 5 | 6 | environment: 7 | sdk: '>=2.17.0 <4.0.0' 8 | flutter: ">=1.17.0" 9 | 10 | dependencies: 11 | flutter: 12 | sdk: flutter 13 | collection: ^1.15.0 14 | 15 | dev_dependencies: 16 | flutter_test: 17 | sdk: flutter 18 | flutter: 19 | -------------------------------------------------------------------------------- /.github/workflows/tests.yml: -------------------------------------------------------------------------------- 1 | name: Tests 2 | 3 | on: 4 | push: 5 | branches: [ master ] 6 | pull_request: 7 | branches: [ master ] 8 | 9 | jobs: 10 | tests: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Checkout code 14 | uses: actions/checkout@v2 15 | - name: Install and set Flutter version 16 | uses: subosito/flutter-action@v1.5.3 17 | with: 18 | channel: 'beta' 19 | - name: Get packages 20 | run: flutter pub get 21 | # - name: Analyze 22 | # run: flutter analyze 23 | - name: Run tests 24 | run: flutter test 25 | -------------------------------------------------------------------------------- /lib/layered/SugiyamaNodeData.dart: -------------------------------------------------------------------------------- 1 | part of graphview; 2 | 3 | class SugiyamaNodeData { 4 | Set reversed = {}; 5 | bool isDummy = false; 6 | int median = -1; 7 | int layer = -1; 8 | int position = -1; 9 | List predecessorNodes = []; 10 | List successorNodes = []; 11 | LineType lineType; 12 | 13 | SugiyamaNodeData(this.lineType); 14 | 15 | bool get isReversed => reversed.isNotEmpty; 16 | 17 | @override 18 | String toString() { 19 | return 'SugiyamaNodeData{reversed: $reversed, isDummy: $isDummy, median: $median, layer: $layer, position: $position, lineType: $lineType}'; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: ['https://www.paypal.me/nabil6391'] 13 | -------------------------------------------------------------------------------- /example/.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | 12 | # IntelliJ related 13 | *.iml 14 | *.ipr 15 | *.iws 16 | .idea/ 17 | 18 | # The .vscode folder contains launch configuration and tasks you configure in 19 | # VS Code which you may wish to be included in version control, so this line 20 | # is commented out by default. 21 | #.vscode/ 22 | 23 | # Flutter/Dart/Pub related 24 | **/doc/api/ 25 | **/ios/Flutter/.last_build_id 26 | .dart_tool/ 27 | .flutter-plugins 28 | .flutter-plugins-dependencies 29 | .packages 30 | .pub-cache/ 31 | .pub/ 32 | /build/ 33 | 34 | # Web related 35 | 36 | # Symbolication related 37 | app.*.symbols 38 | 39 | # Obfuscation related 40 | app.*.map.json 41 | -------------------------------------------------------------------------------- /lib/mindmap/MindmapEdgeRenderer.dart: -------------------------------------------------------------------------------- 1 | part of graphview; 2 | 3 | class MindmapEdgeRenderer extends TreeEdgeRenderer { 4 | MindmapEdgeRenderer(BuchheimWalkerConfiguration configuration) 5 | : super(configuration); 6 | 7 | @override 8 | int getEffectiveOrientation(dynamic node, dynamic child) { 9 | var orientation = configuration.orientation; 10 | 11 | if (child.y < 0) { 12 | if (configuration.orientation == BuchheimWalkerConfiguration.ORIENTATION_TOP_BOTTOM) { 13 | orientation = BuchheimWalkerConfiguration.ORIENTATION_BOTTOM_TOP; 14 | } else { 15 | // orientation = BuchheimWalkerConfiguration.ORIENTATION_TOP_BOTTOM; 16 | } 17 | } else if (child.x < 0) { 18 | if (configuration.orientation == BuchheimWalkerConfiguration.ORIENTATION_LEFT_RIGHT) { 19 | orientation = BuchheimWalkerConfiguration.ORIENTATION_RIGHT_LEFT; 20 | } else { 21 | orientation = BuchheimWalkerConfiguration.ORIENTATION_LEFT_RIGHT; 22 | } 23 | } 24 | 25 | return orientation; 26 | } 27 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Nabil Mosharraf 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /lib/tree/BuchheimWalkerConfiguration.dart: -------------------------------------------------------------------------------- 1 | part of graphview; 2 | 3 | class BuchheimWalkerConfiguration { 4 | int siblingSeparation = DEFAULT_SIBLING_SEPARATION; 5 | int levelSeparation = DEFAULT_LEVEL_SEPARATION; 6 | int subtreeSeparation = DEFAULT_SUBTREE_SEPARATION; 7 | int orientation = DEFAULT_ORIENTATION; 8 | static const ORIENTATION_TOP_BOTTOM = 1; 9 | static const ORIENTATION_BOTTOM_TOP = 2; 10 | static const ORIENTATION_LEFT_RIGHT = 3; 11 | static const ORIENTATION_RIGHT_LEFT = 4; 12 | static const DEFAULT_SIBLING_SEPARATION = 100; 13 | static const DEFAULT_SUBTREE_SEPARATION = 100; 14 | static const DEFAULT_LEVEL_SEPARATION = 100; 15 | static const DEFAULT_ORIENTATION = 1; 16 | bool useCurvedConnections = true; 17 | 18 | int getSiblingSeparation() { 19 | return siblingSeparation; 20 | } 21 | 22 | int getLevelSeparation() { 23 | return levelSeparation; 24 | } 25 | 26 | int getSubtreeSeparation() { 27 | return subtreeSeparation; 28 | } 29 | BuchheimWalkerConfiguration( 30 | {this.siblingSeparation = DEFAULT_SIBLING_SEPARATION, 31 | this.levelSeparation = DEFAULT_LEVEL_SEPARATION, 32 | this.subtreeSeparation = DEFAULT_SUBTREE_SEPARATION, 33 | this.orientation = DEFAULT_ORIENTATION}); 34 | 35 | } 36 | -------------------------------------------------------------------------------- /lib/forcedirected/FruchtermanReingoldConfiguration.dart: -------------------------------------------------------------------------------- 1 | part of graphview; 2 | 3 | class FruchtermanReingoldConfiguration { 4 | static const int DEFAULT_ITERATIONS = 100; 5 | static const double DEFAULT_REPULSION_RATE = 0.2; 6 | static const double DEFAULT_REPULSION_PERCENTAGE = 0.4; 7 | static const double DEFAULT_ATTRACTION_RATE = 0.15; 8 | static const double DEFAULT_ATTRACTION_PERCENTAGE = 0.15; 9 | static const int DEFAULT_CLUSTER_PADDING = 15; 10 | static const double DEFAULT_EPSILON = 0.0001; 11 | static const double DEFAULT_LERP_FACTOR = 0.05; 12 | static const double DEFAULT_MOVEMENT_THRESHOLD = 0.6; 13 | 14 | int iterations; 15 | double repulsionRate; 16 | double repulsionPercentage; 17 | double attractionRate; 18 | double attractionPercentage; 19 | int clusterPadding; 20 | double epsilon; 21 | double lerpFactor; 22 | double movementThreshold; 23 | bool shuffleNodes = true; 24 | 25 | FruchtermanReingoldConfiguration({ 26 | this.iterations = DEFAULT_ITERATIONS, 27 | this.repulsionRate = DEFAULT_REPULSION_RATE, 28 | this.attractionRate = DEFAULT_ATTRACTION_RATE, 29 | this.repulsionPercentage = DEFAULT_REPULSION_PERCENTAGE, 30 | this.attractionPercentage = DEFAULT_ATTRACTION_PERCENTAGE, 31 | this.clusterPadding = DEFAULT_CLUSTER_PADDING, 32 | this.epsilon = DEFAULT_EPSILON, 33 | this.lerpFactor = DEFAULT_LERP_FACTOR, 34 | this.movementThreshold = DEFAULT_MOVEMENT_THRESHOLD, 35 | this.shuffleNodes = true 36 | }); 37 | 38 | } -------------------------------------------------------------------------------- /example/analysis_options.yaml: -------------------------------------------------------------------------------- 1 | # This file configures the analyzer, which statically analyzes Dart code to 2 | # check for errors, warnings, and lints. 3 | # 4 | # The issues identified by the analyzer are surfaced in the UI of Dart-enabled 5 | # IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be 6 | # invoked from the command line by running `flutter analyze`. 7 | 8 | # The following line activates a set of recommended lints for Flutter apps, 9 | # packages, and plugins designed to encourage good coding practices. 10 | include: package:flutter_lints/flutter.yaml 11 | 12 | linter: 13 | # The lint rules applied to this project can be customized in the 14 | # section below to disable rules from the `package:flutter_lints/flutter.yaml` 15 | # included above or to enable additional rules. A list of all available lints 16 | # and their documentation is published at 17 | # https://dart-lang.github.io/linter/lints/index.html. 18 | # 19 | # Instead of disabling a lint rule for the entire project in the 20 | # section below, it can also be suppressed for a single line of code 21 | # or a specific dart file by using the `// ignore: name_of_lint` and 22 | # `// ignore_for_file: name_of_lint` syntax on the line or in the file 23 | # producing the lint. 24 | rules: 25 | avoid_print: false # Uncomment to disable the `avoid_print` rule 26 | prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule 27 | 28 | # Additional information about this file can be found at 29 | # https://dart.dev/guides/language/analysis-options 30 | -------------------------------------------------------------------------------- /analysis_options.yaml: -------------------------------------------------------------------------------- 1 | linter: 2 | rules: 3 | - always_declare_return_types 4 | - annotate_overrides 5 | - avoid_empty_else 6 | - avoid_init_to_null 7 | - avoid_null_checks_in_equality_operators 8 | - avoid_relative_lib_imports 9 | - avoid_return_types_on_setters 10 | - avoid_shadowing_type_parameters 11 | - avoid_types_as_parameter_names 12 | - camel_case_extensions 13 | - curly_braces_in_flow_control_structures 14 | - empty_catches 15 | - empty_constructor_bodies 16 | - library_names 17 | - library_prefixes 18 | - no_duplicate_case_values 19 | - null_closures 20 | - omit_local_variable_types 21 | - prefer_adjacent_string_concatenation 22 | - prefer_collection_literals 23 | - prefer_conditional_assignment 24 | - prefer_contains 25 | # REMOVED: prefer_equal_for_default_values (removed in Dart 3.0) 26 | - prefer_final_fields 27 | - prefer_for_elements_to_map_fromIterable 28 | - prefer_generic_function_type_aliases 29 | - prefer_if_null_operators 30 | - prefer_is_empty 31 | - prefer_is_not_empty 32 | - prefer_iterable_whereType 33 | - prefer_single_quotes 34 | - prefer_spread_collections 35 | - recursive_getters 36 | - slash_for_doc_comments 37 | - type_init_formals 38 | - unawaited_futures 39 | - unnecessary_const 40 | - unnecessary_new 41 | - unnecessary_null_in_if_null_operators 42 | - unnecessary_this 43 | - unrelated_type_equality_checks 44 | - use_function_type_syntax_for_parameters 45 | - use_rethrow_when_possible 46 | - valid_regexps 47 | 48 | analyzer: 49 | strong-mode: 50 | implicit-casts: false -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Miscellaneous 2 | *.class 3 | *.log 4 | *.pyc 5 | *.swp 6 | .DS_Store 7 | .atom/ 8 | .buildlog/ 9 | .history 10 | .svn/ 11 | 12 | # IntelliJ related 13 | *.iml 14 | *.ipr 15 | *.iws 16 | .idea/ 17 | 18 | # The .vscode folder contains launch configuration and tasks you configure in 19 | # VS Code which you may wish to be included in version control, so this line 20 | # is commented out by default. 21 | #.vscode/ 22 | 23 | # Flutter/Dart/Pub related 24 | **/doc/api/ 25 | .dart_tool/ 26 | .flutter-plugins 27 | .flutter-plugins-dependencies 28 | .packages 29 | .pub-cache/ 30 | .pub/ 31 | build/ 32 | 33 | # Android related 34 | **/android/**/gradle-wrapper.jar 35 | **/android/.gradle 36 | **/android/captures/ 37 | **/android/gradlew 38 | **/android/gradlew.bat 39 | **/android/local.properties 40 | **/android/**/GeneratedPluginRegistrant.java 41 | 42 | # iOS/XCode related 43 | **/ios/**/*.mode1v3 44 | **/ios/**/*.mode2v3 45 | **/ios/**/*.moved-aside 46 | **/ios/**/*.pbxuser 47 | **/ios/**/*.perspectivev3 48 | **/ios/**/*sync/ 49 | **/ios/**/.sconsign.dblite 50 | **/ios/**/.tags* 51 | **/ios/**/.vagrant/ 52 | **/ios/**/DerivedData/ 53 | **/ios/**/Icon? 54 | **/ios/**/Pods/ 55 | **/ios/**/.symlinks/ 56 | **/ios/**/profile 57 | **/ios/**/xcuserdata 58 | **/ios/.generated/ 59 | **/ios/Flutter/App.framework 60 | **/ios/Flutter/Flutter.framework 61 | **/ios/Flutter/Flutter.podspec 62 | **/ios/Flutter/Generated.xcconfig 63 | **/ios/Flutter/app.flx 64 | **/ios/Flutter/app.zip 65 | **/ios/Flutter/flutter_assets/ 66 | **/ios/Flutter/flutter_export_environment.sh 67 | **/ios/ServiceDefinitions.json 68 | **/ios/Runner/GeneratedPluginRegistrant.* 69 | 70 | # Exceptions to above rules. 71 | !**/ios/**/default.mode1v3 72 | !**/ios/**/default.mode2v3 73 | !**/ios/**/default.pbxuser 74 | !**/ios/**/default.perspectivev3 75 | AGENTS.md 76 | -------------------------------------------------------------------------------- /lib/layered/SugiyamaConfiguration.dart: -------------------------------------------------------------------------------- 1 | part of graphview; 2 | 3 | class SugiyamaConfiguration { 4 | static const ORIENTATION_TOP_BOTTOM = 1; 5 | static const ORIENTATION_BOTTOM_TOP = 2; 6 | static const ORIENTATION_LEFT_RIGHT = 3; 7 | static const ORIENTATION_RIGHT_LEFT = 4; 8 | static const DEFAULT_ORIENTATION = 1; 9 | static const int DEFAULT_ITERATIONS = 10; 10 | 11 | static const int X_SEPARATION = 100; 12 | static const int Y_SEPARATION = 100; 13 | 14 | int levelSeparation = Y_SEPARATION; 15 | int nodeSeparation = X_SEPARATION; 16 | int orientation = DEFAULT_ORIENTATION; 17 | int iterations = DEFAULT_ITERATIONS; 18 | BendPointShape bendPointShape = SharpBendPointShape(); 19 | CoordinateAssignment coordinateAssignment = CoordinateAssignment.Average; 20 | 21 | LayeringStrategy layeringStrategy = LayeringStrategy.topDown; 22 | CrossMinimizationStrategy crossMinimizationStrategy = CrossMinimizationStrategy.simple; 23 | CycleRemovalStrategy cycleRemovalStrategy = CycleRemovalStrategy.greedy; 24 | 25 | bool postStraighten = true; 26 | 27 | bool addTriangleToEdge = true; 28 | 29 | int getLevelSeparation() { 30 | return levelSeparation; 31 | } 32 | 33 | int getNodeSeparation() { 34 | return nodeSeparation; 35 | } 36 | 37 | int getOrientation() { 38 | return orientation; 39 | } 40 | } 41 | 42 | enum CoordinateAssignment { 43 | DownRight, // 0 44 | DownLeft, // 1 45 | UpRight, // 2 46 | UpLeft, // 3 47 | Average, // 4 48 | } 49 | 50 | enum LayeringStrategy { 51 | topDown, 52 | longestPath, 53 | coffmanGraham, 54 | networkSimplex 55 | } 56 | 57 | enum CrossMinimizationStrategy { 58 | simple, 59 | accumulatorTree 60 | } 61 | 62 | enum CycleRemovalStrategy { 63 | dfs, 64 | greedy, 65 | } 66 | 67 | abstract class BendPointShape {} 68 | 69 | class SharpBendPointShape extends BendPointShape {} 70 | 71 | class MaxCurvedBendPointShape extends BendPointShape {} 72 | 73 | class CurvedBendPointShape extends BendPointShape { 74 | final double curveLength; 75 | 76 | CurvedBendPointShape({ 77 | required this.curveLength, 78 | }); 79 | } 80 | -------------------------------------------------------------------------------- /example/lib/decision_tree_screen.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:graphview/GraphView.dart'; 5 | 6 | class DecisionTreeScreen extends StatefulWidget { 7 | @override 8 | _DecisionTreeScreenState createState() => _DecisionTreeScreenState(); 9 | } 10 | 11 | class _DecisionTreeScreenState extends State { 12 | final _graph = Graph()..isTree = true; 13 | 14 | final _configuration = SugiyamaConfiguration() 15 | ..orientation = 1 16 | ..nodeSeparation = 40 17 | ..levelSeparation = 50; 18 | 19 | @override 20 | void initState() { 21 | super.initState(); 22 | 23 | _graph.addEdge(Node.Id(1), Node.Id(2)); 24 | _graph.addEdge(Node.Id(2), Node.Id(3)); 25 | _graph.addEdge(Node.Id(2), Node.Id(11)); 26 | _graph.addEdge(Node.Id(3), Node.Id(4)); 27 | _graph.addEdge(Node.Id(4), Node.Id(5)); 28 | 29 | _graph.addEdge(Node.Id(1), Node.Id(6)); 30 | _graph.addEdge(Node.Id(6), Node.Id(7)); 31 | _graph.addEdge(Node.Id(7), Node.Id(3)); 32 | 33 | _graph.addEdge(Node.Id(1), Node.Id(10)); 34 | _graph.addEdge(Node.Id(10), Node.Id(11)); 35 | _graph.addEdge(Node.Id(11), Node.Id(7)); 36 | } 37 | 38 | @override 39 | Widget build(BuildContext context) { 40 | return Scaffold( 41 | appBar: AppBar(), 42 | body: InteractiveViewer( 43 | minScale: 0.1, 44 | constrained: false, 45 | boundaryMargin: const EdgeInsets.all(64), 46 | child: GraphView( 47 | graph: _graph, 48 | algorithm: SugiyamaAlgorithm(_configuration), 49 | builder: (node) { 50 | final id = node.key!.value as int; 51 | 52 | final text = List.generate(id == 1 || id == 4 ? 500 : 10, (index) => 'X').join(' '); 53 | 54 | return Container( 55 | width: 180, 56 | decoration: BoxDecoration( 57 | color: Color((Random().nextDouble() * 0xFFFFFF).toInt()).withValues(alpha: 1.0), 58 | border: Border.all(width: 2), 59 | ), 60 | padding: const EdgeInsets.all(16), 61 | child: Text('$id $text'), 62 | ); 63 | }, 64 | ), 65 | ), 66 | ); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /test/algorithm_performance_test.dart: -------------------------------------------------------------------------------- 1 | import 'dart:ui'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:flutter_test/flutter_test.dart'; 5 | import 'package:graphview/GraphView.dart'; 6 | 7 | const itemHeight = 100.0; 8 | const itemWidth = 100.0; 9 | const runs = 5; 10 | 11 | void main() { 12 | Graph _createGraph(int n) { 13 | final graph = Graph(); 14 | final nodes = List.generate(n, (i) => Node.Id(i + 1)); 15 | for (var i = 0; i < n - 1; i++) { 16 | final children = (i < n / 3) ? 3 : 2; 17 | for (var j = 1; j <= children && i * children + j < n; j++) { 18 | graph.addEdge(nodes[i], nodes[i * children + j]); 19 | } 20 | } 21 | for (var i = 0; i < graph.nodeCount(); i++) { 22 | graph.getNodeAtPosition(i).size = const Size(itemWidth, itemHeight); 23 | } 24 | return graph; 25 | } 26 | 27 | test('Algorithm performance', () { 28 | final algorithms = { 29 | 'Buchheim': BuchheimWalkerAlgorithm(BuchheimWalkerConfiguration(), null), 30 | 'Balloon': BalloonLayoutAlgorithm(BuchheimWalkerConfiguration(), null), 31 | 'RadialTree': RadialTreeLayoutAlgorithm(BuchheimWalkerConfiguration(), null), 32 | 'TidierTree': TidierTreeLayoutAlgorithm(BuchheimWalkerConfiguration(), null), 33 | 'Eiglsperger': EiglspergerAlgorithm(SugiyamaConfiguration()), 34 | 'Sugiyama': SugiyamaAlgorithm(SugiyamaConfiguration()), 35 | 'Circle': CircleLayoutAlgorithm(CircleLayoutConfiguration(), null), 36 | }; 37 | 38 | final results = {}; 39 | final graph = _createGraph(1000); 40 | 41 | for (final entry in algorithms.entries) { 42 | final times = []; 43 | for (var i = 0; i < runs; i++) { 44 | final sw = Stopwatch()..start(); 45 | entry.value.run(graph, 0, 0); 46 | times.add(sw.elapsed.inMilliseconds); 47 | } 48 | // results[entry.key] = times.reduce((a, b) => a + b) / times.length; 49 | results[entry.key] = times.reduce((a, b) => a + b).toDouble(); 50 | } 51 | 52 | final sorted = results.entries.toList()..sort((a, b) => a.value.compareTo(b.value)); 53 | 54 | print('\nPerformance Results (${runs} runs avg):'); 55 | for (var i = 0; i < sorted.length; i++) { 56 | print('${(i + 1).toString().padLeft(2)}. ${sorted[i].key.padRight(12)}: ${sorted[i].value.toStringAsFixed(1)} ms'); 57 | } 58 | 59 | for (final result in results.values) { 60 | expect(result < 30000, true); 61 | } 62 | }); 63 | } -------------------------------------------------------------------------------- /example/lib/example.dart: -------------------------------------------------------------------------------- 1 | import 'package:example/layer_graphview.dart'; 2 | import 'package:flutter/material.dart'; 3 | 4 | import 'force_directed_graphview.dart'; 5 | import 'tree_graphview.dart'; 6 | 7 | void main() { 8 | runApp(MyApp()); 9 | } 10 | 11 | class MyApp extends StatelessWidget { 12 | @override 13 | Widget build(BuildContext context) { 14 | return MaterialApp( 15 | home: Home(), 16 | ); 17 | } 18 | } 19 | 20 | class Home extends StatelessWidget { 21 | const Home({ 22 | Key? key, 23 | }) : super(key: key); 24 | 25 | @override 26 | Widget build(BuildContext context) { 27 | return SafeArea( 28 | child: Scaffold( 29 | body: Center( 30 | child: Column(children: [ 31 | TextButton( 32 | onPressed: () => Navigator.push( 33 | context, 34 | MaterialPageRoute( 35 | builder: (context) => Scaffold( 36 | appBar: AppBar(), 37 | body: TreeViewPage(), 38 | )), 39 | ), 40 | child: Text( 41 | 'Tree View (BuchheimWalker)', 42 | style: TextStyle(color: Theme.of(context).primaryColor), 43 | )), 44 | TextButton( 45 | onPressed: () => Navigator.push( 46 | context, 47 | MaterialPageRoute( 48 | builder: (context) => Scaffold( 49 | appBar: AppBar(), 50 | body: GraphClusterViewPage(), 51 | )), 52 | ), 53 | child: Text( 54 | 'Graph Cluster View (FruchtermanReingold)', 55 | style: TextStyle(color: Theme.of(context).primaryColor), 56 | )), 57 | TextButton( 58 | onPressed: () => Navigator.push( 59 | context, 60 | MaterialPageRoute( 61 | builder: (context) => Scaffold( 62 | appBar: AppBar(), 63 | body: LayeredGraphViewPage(), 64 | )), 65 | ), 66 | child: Text( 67 | 'Layered View (Sugiyama)', 68 | style: TextStyle(color: Theme.of(context).primaryColor), 69 | )), 70 | ]), 71 | ), 72 | ), 73 | ); 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /example/lib/graph_cluster_animated.dart: -------------------------------------------------------------------------------- 1 | import 'dart:async'; 2 | import 'dart:math'; 3 | 4 | import 'package:flutter/material.dart'; 5 | import 'package:graphview/GraphView.dart'; 6 | 7 | class GraphScreen extends StatefulWidget { 8 | Graph graph; 9 | FruchtermanReingoldAlgorithm algorithm; 10 | final Paint? paint; 11 | 12 | GraphScreen(this.graph, this.algorithm, this.paint); 13 | 14 | @override 15 | _GraphScreenState createState() => _GraphScreenState(); 16 | } 17 | 18 | class _GraphScreenState extends State { 19 | bool animated = true; 20 | Random r = Random(); 21 | 22 | @override 23 | Widget build(BuildContext context) { 24 | return Scaffold( 25 | appBar: AppBar( 26 | title: Text('Graph Screen'), 27 | actions: [ 28 | IconButton( 29 | icon: Icon(Icons.add), 30 | onPressed: () async { 31 | setState(() { 32 | final node12 = Node.Id(r.nextInt(100).toString()); 33 | var edge = widget.graph.getNodeAtPosition(r.nextInt(widget.graph.nodeCount())); 34 | print(edge); 35 | widget.graph.addEdge(edge, node12); 36 | setState(() {}); 37 | }); 38 | }, 39 | ), 40 | IconButton( 41 | icon: Icon(Icons.animation), 42 | onPressed: () async { 43 | setState(() { 44 | animated = !animated; 45 | }); 46 | }, 47 | ) 48 | ], 49 | ), 50 | body: InteractiveViewer( 51 | constrained: false, 52 | boundaryMargin: EdgeInsets.all(100), 53 | minScale: 0.0001, 54 | maxScale: 10.6, 55 | child: GraphViewCustomPainter( 56 | graph: widget.graph, 57 | algorithm: widget.algorithm, 58 | builder: (Node node) { 59 | // I can decide what widget should be shown here based on the id 60 | var a = node.key!.value as String; 61 | return rectangWidget(a); 62 | }, 63 | )), 64 | ); 65 | } 66 | 67 | Widget rectangWidget(String? i) { 68 | return Container( 69 | padding: EdgeInsets.all(16), 70 | decoration: BoxDecoration( 71 | borderRadius: BorderRadius.circular(4), 72 | boxShadow: [ 73 | BoxShadow(color: Colors.blue, spreadRadius: 1), 74 | ], 75 | ), 76 | child: Center(child: Text('Node $i'))); 77 | } 78 | 79 | Future update() async { 80 | setState(() {}); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /example/lib/force_directed_graphview.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:graphview/GraphView.dart'; 5 | 6 | class GraphClusterViewPage extends StatefulWidget { 7 | @override 8 | _GraphClusterViewPageState createState() => _GraphClusterViewPageState(); 9 | } 10 | 11 | class _GraphClusterViewPageState extends State { 12 | @override 13 | Widget build(BuildContext context) { 14 | return Scaffold( 15 | appBar: AppBar(), 16 | body: Column( 17 | children: [ 18 | Expanded( 19 | child: InteractiveViewer( 20 | constrained: false, 21 | boundaryMargin: EdgeInsets.all(8), 22 | minScale: 0.001, 23 | maxScale: 10000, 24 | child: GraphViewCustomPainter( 25 | graph: graph, 26 | algorithm: algorithm, 27 | paint: Paint() 28 | ..color = Colors.green 29 | ..strokeWidth = 1 30 | ..style = PaintingStyle.fill, 31 | builder: (Node node) { 32 | // I can decide what widget should be shown here based on the id 33 | var a = node.key!.value as int?; 34 | if (a == 2) { 35 | return rectangWidget(a); 36 | } 37 | return rectangWidget(a); 38 | })), 39 | ), 40 | ], 41 | )); 42 | } 43 | 44 | int n = 8; 45 | Random r = Random(); 46 | 47 | Widget rectangWidget(int? i) { 48 | return Container( 49 | padding: EdgeInsets.all(16), 50 | decoration: BoxDecoration( 51 | borderRadius: BorderRadius.circular(4), 52 | boxShadow: [ 53 | BoxShadow(color: Colors.blue, spreadRadius: 1), 54 | ], 55 | ), 56 | child: Text('Node $i')); 57 | } 58 | 59 | final Graph graph = Graph(); 60 | late FruchtermanReingoldAlgorithm algorithm; 61 | 62 | @override 63 | void initState() { 64 | final a = Node.Id(1); 65 | final b = Node.Id(2); 66 | final c = Node.Id(3); 67 | final d = Node.Id(4); 68 | final e = Node.Id(5); 69 | final f = Node.Id(6); 70 | final g = Node.Id(7); 71 | final h = Node.Id(8); 72 | 73 | graph.addEdge(a, b, paint: Paint()..color = Colors.red); 74 | graph.addEdge(a, c); 75 | graph.addEdge(a, d); 76 | graph.addEdge(c, e); 77 | graph.addEdge(d, f); 78 | graph.addEdge(f, c); 79 | graph.addEdge(g, c); 80 | graph.addEdge(h, g); 81 | var config = FruchtermanReingoldConfiguration() 82 | ..iterations = 1000; 83 | algorithm = FruchtermanReingoldAlgorithm(config); 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /example/pubspec.yaml: -------------------------------------------------------------------------------- 1 | name: example 2 | description: A new Flutter project. 3 | 4 | # The following line prevents the package from being accidentally published to 5 | # pub.dev using `pub publish`. This is preferred for private packages. 6 | publish_to: 'none' # Remove this line if you wish to publish to pub.dev 7 | 8 | # The following defines the version and build number for your application. 9 | # A version number is three numbers separated by dots, like 1.2.43 10 | # followed by an optional build number separated by a +. 11 | # Both the version and the builder number may be overridden in flutter 12 | # build by specifying --build-name and --build-number, respectively. 13 | # In Android, build-name is used as versionName while build-number used as versionCode. 14 | # Read more about Android versioning at https://developer.android.com/studio/publish/versioning 15 | # In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. 16 | # Read more about iOS versioning at 17 | # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html 18 | version: 1.0.0+1 19 | 20 | environment: 21 | sdk: '>=2.15.0 <4.0.0' 22 | 23 | dependencies: 24 | flutter: 25 | sdk: flutter 26 | graphview: 27 | path: ../ 28 | provider: ^6.0.3 29 | 30 | dev_dependencies: 31 | flutter_test: 32 | sdk: flutter 33 | 34 | # For information on the generic Dart part of this file, see the 35 | # following page: https://dart.dev/tools/pub/pubspec 36 | 37 | # The following section is specific to Flutter. 38 | flutter: 39 | 40 | # The following line ensures that the Material Icons font is 41 | # included with your application, so that you can use the icons in 42 | # the material Icons class. 43 | uses-material-design: true 44 | 45 | # To add assets to your application, add an assets section, like this: 46 | # assets: 47 | # - images/a_dot_burr.jpeg 48 | # - images/a_dot_ham.jpeg 49 | 50 | # An image asset can refer to one or more resolution-specific "variants", see 51 | # https://flutter.dev/assets-and-images/#resolution-aware. 52 | 53 | # For details regarding adding assets from package dependencies, see 54 | # https://flutter.dev/assets-and-images/#from-packages 55 | 56 | # To add custom fonts to your application, add a fonts section here, 57 | # in this "flutter" section. Each entry in this list should have a 58 | # "family" key with the font family name, and a "fonts" key with a 59 | # list giving the asset and other descriptors for the font. For 60 | # example: 61 | # fonts: 62 | # - family: Schyler 63 | # fonts: 64 | # - asset: fonts/Schyler-Regular.ttf 65 | # - asset: fonts/Schyler-Italic.ttf 66 | # style: italic 67 | # - family: Trajan Pro 68 | # fonts: 69 | # - asset: fonts/TrajanPro.ttf 70 | # - asset: fonts/TrajanPro_Bold.ttf 71 | # weight: 700 72 | # 73 | # For details regarding fonts from package dependencies, 74 | # see https://flutter.dev/custom-fonts/#from-packages 75 | -------------------------------------------------------------------------------- /lib/mindmap/MindMapAlgorithm.dart: -------------------------------------------------------------------------------- 1 | part of graphview; 2 | 3 | enum MindmapSide { LEFT, RIGHT, ROOT } 4 | 5 | class _SideData { 6 | MindmapSide side = MindmapSide.ROOT; 7 | } 8 | 9 | class MindmapAlgorithm extends BuchheimWalkerAlgorithm { 10 | final Map _side = {}; 11 | 12 | MindmapAlgorithm(BuchheimWalkerConfiguration config, EdgeRenderer? renderer) 13 | : super(config, renderer ?? MindmapEdgeRenderer(config)); 14 | 15 | @override 16 | void initData(Graph? graph) { 17 | super.initData(graph); 18 | _side.clear(); 19 | graph?.nodes.forEach((n) => _side[n] = _SideData()); 20 | } 21 | 22 | @override 23 | Size run(Graph? graph, double shiftX, double shiftY) { 24 | initData(graph); 25 | _detectCycles(graph!); 26 | final root = getFirstNode(graph); 27 | _applyBuchheimWalkerSpacing(graph, root); 28 | _createMindmapLayout(graph, root); 29 | shiftCoordinates(graph, shiftX, shiftY); 30 | return graph.calculateGraphSize(); 31 | } 32 | 33 | void _markSubtree(Node node, MindmapSide side) { 34 | final d = _side[node]!; 35 | d.side = side; 36 | 37 | for (final child in successorsOf(node)) { 38 | _markSubtree(child, side); 39 | } 40 | } 41 | 42 | void _applyBuchheimWalkerSpacing(Graph graph, Node root) { 43 | // Apply the standard Buchheim-Walker algorithm to get proper spacing 44 | // This gives us optimal spacing relationships between all nodes 45 | firstWalk(graph, root, 0, 0); 46 | secondWalk(graph, root, 0.0); 47 | positionNodes(graph); 48 | 49 | // At this point, all nodes have positions with proper spacing, 50 | // but they're in a traditional tree layout. We'll reposition them next. 51 | } 52 | 53 | void _createMindmapLayout(Graph graph, Node root) { 54 | final vertical = isVertical(); 55 | final rootPos = vertical ? root.x : root.y; 56 | 57 | // Mark subtrees and position nodes in one pass 58 | for (final child in successorsOf(root)) { 59 | final childPos = vertical ? child.x : child.y; 60 | final side = childPos < rootPos ? MindmapSide.LEFT : MindmapSide.RIGHT; 61 | _markSubtree(child, side); 62 | } 63 | 64 | // Position all non-root nodes 65 | for (final node in graph.nodes) { 66 | final info = nodeData[node]!; 67 | if (info.depth == 0) continue; // Skip root 68 | 69 | final sideMultiplier = _side[node]!.side == MindmapSide.LEFT ? -1 : 1; 70 | final secondary = vertical ? node.x : node.y; 71 | final distanceFromRoot = info.depth * configuration.levelSeparation + 72 | (vertical ? maxNodeWidth : maxNodeHeight) / 2; 73 | 74 | if (vertical) { 75 | node.position = Offset( 76 | secondary - root.x * 0.5 * sideMultiplier, 77 | sideMultiplier * distanceFromRoot 78 | ); 79 | } else { 80 | node.position = Offset( 81 | sideMultiplier * distanceFromRoot, 82 | secondary - root.y * 0.5 * sideMultiplier 83 | ); 84 | } 85 | } 86 | 87 | // Adjust root and apply final transformations 88 | if (needReverseOrder()) { 89 | if (vertical) { 90 | root.y = 0.0; 91 | } else { 92 | root.x = 0.0; 93 | } 94 | } 95 | 96 | for (final node in graph.nodes) { 97 | final info = nodeData[node]!; 98 | if (info.depth == 0) { 99 | if (vertical) { 100 | node.x = node.x * 0.5; 101 | } else { 102 | node.y = node.y * 0.5; 103 | } 104 | } else { 105 | if (vertical) { 106 | node.x = node.x - root.x; 107 | } else { 108 | node.y = node.y - root.y; 109 | } 110 | } 111 | } 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## 1.5.1 2 | - Fix Zoom To fit for hidden nodes 3 | - Add Fade in Support for Edges 4 | - Add Loopback support 5 | 6 | ## 1.5.0 7 | 8 | - **MAJOR UPDATE**: Added 5 new layout algorithms 9 | - BalloonLayoutAlgorithm: Radial tree layout with circular child arrangements around parents 10 | - CircleLayoutAlgorithm: Arranges nodes in circular formations with edge crossing reduction 11 | - RadialTreeLayoutAlgorithm: Converts tree structures to polar coordinate system 12 | - TidierTreeLayoutAlgorithm: Improved tree layout with better spacing and positioning 13 | - MindmapAlgorithm: Specialized layout for mindmap-style distributions 14 | - **NEW**: Node expand/collapse functionality with GraphViewController 15 | - `collapseNode()`, `expandNode()`, `toggleNodeExpanded()` methods 16 | - Hierarchical visibility control with animated transitions 17 | - Initial collapsed state support via `setInitiallyCollapsedNodes()` 18 | - **NEW**: Advanced animation system 19 | - Smooth expand/collapse animations with customizable duration 20 | - Node scaling and opacity transitions during state changes 21 | - `toggleAnimationDuration` parameter for fine-tuning animations 22 | - **NEW**: Enhanced GraphView.builder constructor 23 | - `animated`: Enable/disable smooth animations (default: true) 24 | - `autoZoomToFit`: Automatically zoom to fit all nodes on initialization 25 | - `initialNode`: Jump to specific node on startup 26 | - `panAnimationDuration`: Customizable navigation movement timing 27 | - `centerGraph`: Center the graph within viewport having a fixed large size of 2000000 28 | - `controller`: GraphViewController for programmatic control 29 | - **NEW**: Navigation and pan control features 30 | - `jumpToNode()` and `animateToNode()` for programmatic navigation 31 | - `zoomToFit()` for automatic viewport adjustment 32 | - `resetView()` for returning to origin 33 | - `forceRecalculation()` for layout updates 34 | - **IMPROVED** TreeEdgeRenderer with curved/straight connection options 35 | - **IMPROVED**: Better performance with caching for graphs 36 | - **IMPROVED**: Sugiyama Algorithm with postStraighten and additional strategies 37 | 38 | ## 1.2.0 39 | 40 | - Resolved Overlaping for Sugiyama Algorithm (#56, #93, #87) 41 | - Added Enum for Coordinate Assignment in Sugiyama : DownRight, DownLeft, UpRight, UpLeft, Average(Default) 42 | 43 | ## 1.1.1 44 | 45 | - Fixed bug for SugiyamaAlgorithm where horizontal placement was overlapping 46 | - Buchheim Algorithm Performance Improvements 47 | 48 | ## 1.1.0 49 | 50 | - Massive Sugiyama Algorithm Performance Improvements! (5x times faster) 51 | - Encourage usage of Node.id(int) for better performance 52 | - Added tests to better check regressions 53 | 54 | ## 1.0.0 55 | 56 | - Full Null Safety Support 57 | - Sugiyama Algorithm Performance Improvements 58 | - Sugiyama Algorithm TOP_BOTTOM Height Issue Solved (#48) 59 | 60 | ## 1.0.0-nullsafety.0 61 | 62 | - Null Safety Support 63 | 64 | ## 0.7.0 65 | 66 | - Added methods for builder pattern and deprecated directly setting Widget Data in nodes. 67 | 68 | ## 0.6.7 69 | 70 | - Fix rect value not being set in FruchtermanReingoldAlgorithm (#27) 71 | 72 | ## 0.6.6 73 | 74 | - Fix Index out of range for Sugiyama Algorithm (#20) 75 | 76 | ## 0.6.5 77 | 78 | - Fix edge coloring not picked up by TreeEdgeRenderer (#15) 79 | - Added Orientation Support in Sugiyama Configuration (#6) 80 | 81 | ## 0.6.1 82 | 83 | - Fix coloring not happening for the whole graphview 84 | - Fix coloring for sugiyama and tree edge render 85 | - Use interactive viewer correctly to make the view constrained 86 | 87 | ## 0.6.0 88 | 89 | - Add coloring to individual edges. Applicable for ArrowEdgeRenderer 90 | - Add example for focused node for Force Directed Graph. It also showcases dynamic update 91 | 92 | ## 0.5.1 93 | 94 | - Fix a bug where the paint was not applied after setstate. 95 | - Proper Key validation to match Nodes and Edges 96 | 97 | ## 0.5.0 98 | 99 | - Minor Breaking change. We now pass edge renderers as part of Layout 100 | - Added Layered Graph (SugiyamaAlgorithm) 101 | - Added Paint Object to change color and stroke parameters of the edges easily 102 | - Fixed a bug where by onTap in GestureDetector and Inkwell was not working 103 | 104 | ## 0.1.2 105 | 106 | - Used part of library properly. Now we can only implement single graphview 107 | 108 | ## 0.1.0 109 | 110 | - Initial release. -------------------------------------------------------------------------------- /test/graph_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/widgets.dart'; 2 | import 'package:graphview/GraphView.dart'; 3 | import 'package:flutter_test/flutter_test.dart'; 4 | 5 | void main() { 6 | group('Graph', () { 7 | test('Graph Node counts are correct', () { 8 | final graph = Graph(); 9 | var node1 = Node.Id('One'); 10 | var node2 = Node.Id('Two'); 11 | var node3 = Node.Id('Three'); 12 | var node4 = Node.Id('Four'); 13 | var node5 = Node.Id('Five'); 14 | var node6 = Node.Id('Six'); 15 | var node7 = Node.Id('Seven'); 16 | var node8 = Node.Id('Eight'); 17 | var node9 = Node.Id('Nine'); 18 | 19 | graph.addEdge(node1, node2); 20 | graph.addEdge(node1, node4); 21 | graph.addEdge(node2, node3); 22 | graph.addEdge(node2, node5); 23 | graph.addEdge(node3, node6); 24 | graph.addEdge(node4, node5); 25 | graph.addEdge(node4, node7); 26 | graph.addEdge(node5, node6); 27 | graph.addEdge(node5, node8); 28 | graph.addEdge(node6, node9); 29 | graph.addEdge(node7, node8); 30 | graph.addEdge(node8, node9); 31 | 32 | expect(graph.nodeCount(), 9); 33 | 34 | graph.removeNode(Node.Id('One')); 35 | graph.removeNode(Node.Id('Ten')); 36 | 37 | expect(graph.nodeCount(), 8); 38 | 39 | graph.addNode(Node.Id('Ten')); 40 | 41 | expect(graph.nodeCount(), 9); 42 | }); 43 | 44 | test('Node Hash Implementation is performant', () { 45 | final graph = Graph(); 46 | 47 | var rows = 1000000; 48 | 49 | var integerNode = Node.Id(1); 50 | var stringNode = Node.Id('123'); 51 | var stringNode2 = Node.Id('G9Q84H1R9-1619338713.000900'); 52 | var widgetNode = Node.Id(Text('Lovely')); 53 | var widgetNode2 = Node.Id(Text('Lovely')); 54 | var doubleNode = Node.Id(5.6); 55 | 56 | var edge = graph.addEdge(integerNode, Node.Id(4)); 57 | 58 | var nodes = [ 59 | integerNode, 60 | stringNode, 61 | stringNode2, 62 | widgetNode, 63 | widgetNode2, 64 | doubleNode 65 | ]; 66 | 67 | for (var node in nodes) { 68 | var stopwatch = Stopwatch() 69 | ..start(); 70 | for (var i = 1; i <= rows; i++) { 71 | var hash = node.hashCode; 72 | } 73 | var timeTaken = stopwatch.elapsed.inMilliseconds; 74 | print('Time taken: $timeTaken ms for ${node.runtimeType} node'); 75 | expect(timeTaken < 100, true); 76 | } 77 | }); 78 | 79 | test('Graph does not duplicate nodes for self loops', () { 80 | final graph = Graph(); 81 | final node = Node.Id('self'); 82 | 83 | graph.addEdge(node, node); 84 | 85 | expect(graph.nodes.length, 1); 86 | expect(graph.edges.length, 1); 87 | expect(graph.nodes.single, node); 88 | }); 89 | 90 | test('ArrowEdgeRenderer builds self-loop path', () { 91 | final renderer = ArrowEdgeRenderer(); 92 | final node = Node.Id('self') 93 | ..size = const Size(40, 40) 94 | ..position = const Offset(100, 100); 95 | 96 | final edge = Edge(node, node); 97 | final result = renderer.buildSelfLoopPath(edge); 98 | 99 | expect(result, isNotNull); 100 | 101 | final metrics = result!.path.computeMetrics().toList(); 102 | expect(metrics, isNotEmpty); 103 | final metric = metrics.first; 104 | expect(metric.length, greaterThan(0)); 105 | expect(result.arrowTip, isNot(equals(const Offset(0, 0)))); 106 | 107 | final tangentStart = metric.getTangentForOffset(0); 108 | expect(tangentStart, isNotNull); 109 | expect(tangentStart!.vector.dy.abs(), 110 | lessThan(tangentStart.vector.dx.abs() * 0.1)); 111 | expect(tangentStart.vector.dx, greaterThan(0)); 112 | 113 | final tangentEnd = metric.getTangentForOffset(metric.length); 114 | expect(tangentEnd, isNotNull); 115 | expect(tangentEnd!.vector.dx.abs(), 116 | lessThan(tangentEnd.vector.dy.abs() * 0.1)); 117 | expect(tangentEnd.vector.dy, greaterThan(0)); 118 | }); 119 | 120 | test('SugiyamaAlgorithm handles single node self loop', () { 121 | final graph = Graph(); 122 | final node = Node.Id('self') 123 | ..size = const Size(40, 40); 124 | 125 | graph.addEdge(node, node); 126 | 127 | final config = SugiyamaConfiguration() 128 | ..nodeSeparation = 20 129 | ..levelSeparation = 20; 130 | 131 | final algorithm = SugiyamaAlgorithm(config); 132 | 133 | expect(() => algorithm.run(graph, 0, 0), returnsNormally); 134 | expect(graph.nodes.length, 1); 135 | }); 136 | }); 137 | } 138 | -------------------------------------------------------------------------------- /test/graphview_perfomance_test.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter/rendering.dart'; 3 | import 'package:flutter_test/flutter_test.dart'; 4 | import 'package:graphview/GraphView.dart'; 5 | 6 | void main() { 7 | group('GraphView Performance Tests', () { 8 | 9 | testWidgets('hitTest performance with 1000+ nodes less than 20s', (WidgetTester tester) async { 10 | final graph = _createLargeGraph(1000); 11 | 12 | final _configuration = BuchheimWalkerConfiguration() 13 | ..siblingSeparation = (100) 14 | ..levelSeparation = (150) 15 | ..subtreeSeparation = (150) 16 | ..orientation = (BuchheimWalkerConfiguration.ORIENTATION_TOP_BOTTOM); 17 | 18 | var algorithm = BuchheimWalkerAlgorithm( 19 | _configuration, TreeEdgeRenderer(_configuration)); 20 | 21 | await tester.pumpWidget(MaterialApp( 22 | home: Scaffold( 23 | body: GraphView.builder( 24 | graph: graph, 25 | algorithm: algorithm, 26 | builder: (Node node) => Container( 27 | width: 50, 28 | height: 50, 29 | decoration: BoxDecoration( 30 | color: Colors.blue, 31 | shape: BoxShape.circle, 32 | ), 33 | child: Center(child: Text(node.key.toString())), 34 | ), 35 | ), 36 | ), 37 | )); 38 | 39 | await tester.pumpAndSettle(); 40 | 41 | final renderBox = tester.renderObject( 42 | find.byType(GraphViewWidget) 43 | ); 44 | 45 | final stopwatch = Stopwatch()..start(); 46 | 47 | // Test multiple hit tests at different positions 48 | for (var i = 0; i < 10; i++) { 49 | final result = BoxHitTestResult(); 50 | renderBox.hitTest(result, position: Offset(i * 10.0, i * 10.0)); 51 | } 52 | 53 | stopwatch.stop(); 54 | final hitTestTime = stopwatch.elapsedMilliseconds; 55 | 56 | print('HitTest time for 1000 nodes (10 tests): ${hitTestTime}ms'); 57 | expect(hitTestTime, lessThan(20), reason: 'HitTest should complete in under 10ms'); 58 | }); 59 | 60 | testWidgets('paint performance with 1000+ nodes', (WidgetTester tester) async { 61 | final graph = _createLargeGraph(1000); 62 | 63 | final _configuration = BuchheimWalkerConfiguration() 64 | ..siblingSeparation = (100) 65 | ..levelSeparation = (150) 66 | ..subtreeSeparation = (150) 67 | ..orientation = (BuchheimWalkerConfiguration.ORIENTATION_TOP_BOTTOM); 68 | 69 | var algorithm = BuchheimWalkerAlgorithm( 70 | _configuration, TreeEdgeRenderer(_configuration)); 71 | 72 | await tester.pumpWidget(MaterialApp( 73 | home: Scaffold( 74 | body: GraphView.builder( 75 | graph: graph, 76 | algorithm: algorithm, 77 | builder: (Node node) => Container( 78 | width: 30, 79 | height: 30, 80 | color: Colors.red, 81 | ), 82 | ), 83 | ), 84 | )); 85 | 86 | final stopwatch = Stopwatch()..start(); 87 | 88 | // Trigger multiple repaints 89 | for (var i = 0; i < 10; i++) { 90 | await tester.pump(); 91 | } 92 | 93 | stopwatch.stop(); 94 | final paintTime = stopwatch.elapsedMilliseconds; 95 | 96 | print('Paint time for 1000 nodes (10 repaints): ${paintTime}ms'); 97 | expect(paintTime, lessThan(50), reason: 'Paint should complete in under 50ms'); 98 | }); 99 | 100 | test('algorithm run performance with 1000+ nodes', () { 101 | final graph = _createLargeGraph(1000); 102 | 103 | final _configuration = BuchheimWalkerConfiguration() 104 | ..siblingSeparation = (100) 105 | ..levelSeparation = (150) 106 | ..subtreeSeparation = (150) 107 | ..orientation = (BuchheimWalkerConfiguration.ORIENTATION_TOP_BOTTOM); 108 | 109 | var algorithm = BuchheimWalkerAlgorithm( 110 | _configuration, TreeEdgeRenderer(_configuration)); 111 | 112 | final stopwatch = Stopwatch()..start(); 113 | 114 | algorithm.run(graph, 0, 0); 115 | 116 | stopwatch.stop(); 117 | final algorithmTime = stopwatch.elapsedMilliseconds; 118 | 119 | print('Algorithm run time for 1000 nodes: ${algorithmTime}ms'); 120 | expect(algorithmTime, lessThan(10), reason: 'Algorithm should complete in under 10 milisecond'); 121 | }); 122 | 123 | }); 124 | } 125 | 126 | /// Creates a large graph with connected nodes for performance testing 127 | Graph _createLargeGraph(int n) { 128 | final graph = Graph(); 129 | // Create nodes 130 | final nodes = List.generate(n, (i) => Node.Id(i + 1)); 131 | 132 | // Generate tree edges using a queue-based approach 133 | var currentChild = 1; // Start from node 1 (node 0 is root) 134 | 135 | for (var i = 0; i < n && currentChild < n; i++) { 136 | final children = (i < n ~/ 3) ? 3 : 2; 137 | 138 | for (var j = 0; j < children && currentChild < n; j++) { 139 | graph.addEdge(nodes[i], nodes[currentChild]); 140 | currentChild++; 141 | } 142 | } 143 | 144 | return graph; 145 | } -------------------------------------------------------------------------------- /test/buchheim_walker_algorithm_test.dart: -------------------------------------------------------------------------------- 1 | 2 | import 'package:flutter/material.dart'; 3 | import 'package:flutter_test/flutter_test.dart'; 4 | import 'package:graphview/GraphView.dart'; 5 | 6 | const itemHeight = 100.0; 7 | const itemWidth = 100.0; 8 | 9 | void main() { 10 | group('Buchheim Graph', () { 11 | final graph = Graph(); 12 | final node1 = Node.Id(1); 13 | final node2 = Node.Id(2); 14 | final node3 = Node.Id(3); 15 | final node4 = Node.Id(4); 16 | final node5 = Node.Id(5); 17 | final node6 = Node.Id(6); 18 | final node8 = Node.Id(7); 19 | final node7 = Node.Id(8); 20 | final node9 = Node.Id(9); 21 | final node10 = Node.Id(10); 22 | final node11 = Node.Id(11); 23 | final node12 = Node.Id(12); 24 | graph.addEdge(node1, node2); 25 | graph.addEdge(node1, node3, paint: Paint()..color = Colors.red); 26 | graph.addEdge(node1, node4, paint: Paint()..color = Colors.blue); 27 | graph.addEdge(node2, node5); 28 | graph.addEdge(node2, node6); 29 | graph.addEdge(node6, node7, paint: Paint()..color = Colors.red); 30 | graph.addEdge(node6, node8, paint: Paint()..color = Colors.red); 31 | graph.addEdge(node4, node9); 32 | graph.addEdge(node4, node10, paint: Paint()..color = Colors.black); 33 | graph.addEdge(node4, node11, paint: Paint()..color = Colors.red); 34 | graph.addEdge(node11, node12); 35 | 36 | test('Buchheim Node positions are correct for Top_Bottom', () { 37 | final _configuration = BuchheimWalkerConfiguration() 38 | ..siblingSeparation = (100) 39 | ..levelSeparation = (150) 40 | ..subtreeSeparation = (150) 41 | ..orientation = (BuchheimWalkerConfiguration.ORIENTATION_TOP_BOTTOM); 42 | 43 | var algorithm = BuchheimWalkerAlgorithm( 44 | _configuration, TreeEdgeRenderer(_configuration)); 45 | 46 | for (var i = 0; i < graph.nodeCount(); i++) { 47 | graph.getNodeAtPosition(i).size = Size(itemWidth, itemHeight); 48 | } 49 | 50 | var stopwatch = Stopwatch()..start(); 51 | var size = algorithm.run(graph, 10, 10); 52 | var timeTaken = stopwatch.elapsed.inMilliseconds; 53 | 54 | expect(timeTaken < 1000, true); 55 | 56 | expect(graph.getNodeAtPosition(0).position, Offset(385, 10)); 57 | expect(graph.getNodeAtPosition(6).position, Offset(110.0, 760.0)); 58 | expect(graph.getNodeUsingId(3).position, Offset(385.0, 260.0)); 59 | expect(graph.getNodeUsingId(4).position, Offset(660.0, 260.0)); 60 | 61 | expect(size, Size(950.0, 850.0)); 62 | }); 63 | 64 | test('Buchheim detects cyclic dependencies', () { 65 | // Create a graph with a cycle 66 | final cyclicGraph = Graph(); 67 | final nodeA = Node.Id('A'); 68 | final nodeB = Node.Id('B'); 69 | final nodeC = Node.Id('C'); 70 | 71 | // Create cycle: A -> B -> C -> A 72 | cyclicGraph.addEdge(nodeA, nodeB); 73 | cyclicGraph.addEdge(nodeB, nodeC); 74 | cyclicGraph.addEdge(nodeC, nodeA); // This creates the cycle 75 | 76 | for (var i = 0; i < cyclicGraph.nodeCount(); i++) { 77 | cyclicGraph.getNodeAtPosition(i).size = Size(itemWidth, itemHeight); 78 | } 79 | 80 | final configuration = BuchheimWalkerConfiguration() 81 | ..siblingSeparation = (100) 82 | ..levelSeparation = (150) 83 | ..subtreeSeparation = (150) 84 | ..orientation = (BuchheimWalkerConfiguration.ORIENTATION_TOP_BOTTOM); 85 | 86 | var algorithm = BuchheimWalkerAlgorithm( 87 | configuration, TreeEdgeRenderer(configuration)); 88 | 89 | // Should throw exception when cycle is detected 90 | expect( 91 | () => algorithm.run(cyclicGraph, 0, 0), 92 | throwsA(isA().having( 93 | (e) => e.toString(), 94 | 'message', 95 | contains('Cyclic dependency detected'), 96 | )), 97 | ); 98 | }); 99 | 100 | test('Buchheim Performance for 1000 nodes to be less than 40ms', () { 101 | Graph _createGraph(int n) { 102 | final graph = Graph(); 103 | final nodes = List.generate(n, (i) => Node.Id(i + 1)); 104 | var currentChild = 1; // Start from node 1 (node 0 is root) 105 | for (var i = 0; i < n && currentChild < n; i++) { 106 | final children = (i < n ~/ 3) ? 3 : 2; 107 | 108 | for (var j = 0; j < children && currentChild < n; j++) { 109 | graph.addEdge(nodes[i], nodes[currentChild]); 110 | currentChild++; 111 | } 112 | } 113 | for (var i = 0; i < graph.nodeCount(); i++) { 114 | graph.getNodeAtPosition(i).size = const Size(itemWidth, itemHeight); 115 | } 116 | return graph; 117 | } 118 | 119 | final _configuration = BuchheimWalkerConfiguration() 120 | ..siblingSeparation = (100) 121 | ..levelSeparation = (150) 122 | ..subtreeSeparation = (150) 123 | ..orientation = (BuchheimWalkerConfiguration.ORIENTATION_TOP_BOTTOM); 124 | 125 | var algorithm = BuchheimWalkerAlgorithm( 126 | _configuration, TreeEdgeRenderer(_configuration)); 127 | 128 | var graph = _createGraph(1000); 129 | for (var i = 0; i < graph.nodeCount(); i++) { 130 | graph.getNodeAtPosition(i).size = Size(itemWidth, itemHeight); 131 | } 132 | 133 | var stopwatch = Stopwatch()..start(); 134 | algorithm.run(graph, 0, 0); 135 | var timeTaken = stopwatch.elapsed.inMilliseconds; 136 | 137 | print('Timetaken $timeTaken for ${graph.nodeCount()} nodes'); 138 | 139 | expect(timeTaken < 40, true); 140 | }); 141 | }); 142 | } 143 | -------------------------------------------------------------------------------- /example/lib/tree_graphview_json.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:graphview/GraphView.dart'; 3 | 4 | class TreeViewPageFromJson extends StatefulWidget { 5 | @override 6 | _TreeViewPageFromJsonState createState() => _TreeViewPageFromJsonState(); 7 | } 8 | 9 | class _TreeViewPageFromJsonState extends State { 10 | var json = { 11 | 'nodes': [ 12 | {'id': 1, 'label': 'circle'}, 13 | {'id': 2, 'label': 'ellipse'}, 14 | {'id': 3, 'label': 'database'}, 15 | {'id': 4, 'label': 'box'}, 16 | {'id': 5, 'label': 'diamond'}, 17 | {'id': 6, 'label': 'dot'}, 18 | {'id': 7, 'label': 'square'}, 19 | {'id': 8, 'label': 'triangle'}, 20 | ], 21 | 'edges': [ 22 | {'from': 1, 'to': 2}, 23 | {'from': 2, 'to': 3}, 24 | {'from': 2, 'to': 4}, 25 | {'from': 2, 'to': 5}, 26 | {'from': 5, 'to': 6}, 27 | {'from': 5, 'to': 7}, 28 | {'from': 6, 'to': 8} 29 | ] 30 | }; 31 | 32 | @override 33 | Widget build(BuildContext context) { 34 | return Scaffold( 35 | appBar: AppBar(), 36 | body: Column( 37 | mainAxisSize: MainAxisSize.max, 38 | children: [ 39 | Wrap( 40 | children: [ 41 | Container( 42 | width: 100, 43 | child: TextFormField( 44 | initialValue: builder.siblingSeparation.toString(), 45 | decoration: InputDecoration(labelText: 'Sibling Separation'), 46 | onChanged: (text) { 47 | builder.siblingSeparation = int.tryParse(text) ?? 100; 48 | setState(() {}); 49 | }, 50 | ), 51 | ), 52 | Container( 53 | width: 100, 54 | child: TextFormField( 55 | initialValue: builder.levelSeparation.toString(), 56 | decoration: InputDecoration(labelText: 'Level Separation'), 57 | onChanged: (text) { 58 | builder.levelSeparation = int.tryParse(text) ?? 100; 59 | setState(() {}); 60 | }, 61 | ), 62 | ), 63 | Container( 64 | width: 100, 65 | child: TextFormField( 66 | initialValue: builder.subtreeSeparation.toString(), 67 | decoration: InputDecoration(labelText: 'Subtree separation'), 68 | onChanged: (text) { 69 | builder.subtreeSeparation = int.tryParse(text) ?? 100; 70 | setState(() {}); 71 | }, 72 | ), 73 | ), 74 | Container( 75 | width: 100, 76 | child: TextFormField( 77 | initialValue: builder.orientation.toString(), 78 | decoration: InputDecoration(labelText: 'Orientation'), 79 | onChanged: (text) { 80 | builder.orientation = int.tryParse(text) ?? 100; 81 | setState(() {}); 82 | }, 83 | ), 84 | ), 85 | ], 86 | ), 87 | Expanded( 88 | child: InteractiveViewer( 89 | constrained: false, 90 | boundaryMargin: EdgeInsets.all(100), 91 | minScale: 0.01, 92 | maxScale: 5.6, 93 | child: GraphView( 94 | graph: graph, 95 | algorithm: BuchheimWalkerAlgorithm(builder, TreeEdgeRenderer(builder)), 96 | paint: Paint() 97 | ..color = Colors.green 98 | ..strokeWidth = 1 99 | ..style = PaintingStyle.stroke, 100 | builder: (Node node) { 101 | // I can decide what widget should be shown here based on the id 102 | var a = node.key!.value as int?; 103 | var nodes = json['nodes']!; 104 | var nodeValue = nodes.firstWhere((element) => element['id'] == a); 105 | return rectangleWidget(nodeValue['label'] as String?); 106 | }, 107 | )), 108 | ), 109 | ], 110 | )); 111 | } 112 | 113 | Widget rectangleWidget(String? a) { 114 | return InkWell( 115 | onTap: () { 116 | print('clicked'); 117 | }, 118 | child: Container( 119 | padding: EdgeInsets.all(16), 120 | decoration: BoxDecoration( 121 | borderRadius: BorderRadius.circular(4), 122 | boxShadow: [ 123 | BoxShadow(color: Colors.blue[100]!, spreadRadius: 1), 124 | ], 125 | ), 126 | child: Text('${a}')), 127 | ); 128 | } 129 | 130 | final Graph graph = Graph()..isTree = true; 131 | BuchheimWalkerConfiguration builder = BuchheimWalkerConfiguration(); 132 | 133 | @override 134 | void initState() { 135 | super.initState(); 136 | 137 | var edges = json['edges']!; 138 | edges.forEach((element) { 139 | var fromNodeId = element['from']; 140 | var toNodeId = element['to']; 141 | graph.addEdge(Node.Id(fromNodeId), Node.Id(toNodeId)); 142 | }); 143 | 144 | builder 145 | ..siblingSeparation = (100) 146 | ..levelSeparation = (150) 147 | ..subtreeSeparation = (150) 148 | ..orientation = (BuchheimWalkerConfiguration.ORIENTATION_TOP_BOTTOM); 149 | } 150 | } 151 | -------------------------------------------------------------------------------- /pubspec.lock: -------------------------------------------------------------------------------- 1 | # Generated by pub 2 | # See https://dart.dev/tools/pub/glossary#lockfile 3 | packages: 4 | async: 5 | dependency: transitive 6 | description: 7 | name: async 8 | sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" 9 | url: "https://pub.dev" 10 | source: hosted 11 | version: "2.13.0" 12 | boolean_selector: 13 | dependency: transitive 14 | description: 15 | name: boolean_selector 16 | sha256: "5bbf32bc9e518d41ec49718e2931cd4527292c9b0c6d2dffcf7fe6b9a8a8cf72" 17 | url: "https://pub.dev" 18 | source: hosted 19 | version: "2.1.0" 20 | characters: 21 | dependency: transitive 22 | description: 23 | name: characters 24 | sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 25 | url: "https://pub.dev" 26 | source: hosted 27 | version: "1.4.0" 28 | charcode: 29 | dependency: transitive 30 | description: 31 | name: charcode 32 | sha256: fb98c0f6d12c920a02ee2d998da788bca066ca5f148492b7085ee23372b12306 33 | url: "https://pub.dev" 34 | source: hosted 35 | version: "1.3.1" 36 | clock: 37 | dependency: transitive 38 | description: 39 | name: clock 40 | sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b 41 | url: "https://pub.dev" 42 | source: hosted 43 | version: "1.1.2" 44 | collection: 45 | dependency: "direct main" 46 | description: 47 | name: collection 48 | sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" 49 | url: "https://pub.dev" 50 | source: hosted 51 | version: "1.19.1" 52 | fake_async: 53 | dependency: transitive 54 | description: 55 | name: fake_async 56 | sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" 57 | url: "https://pub.dev" 58 | source: hosted 59 | version: "1.3.3" 60 | flutter: 61 | dependency: "direct main" 62 | description: flutter 63 | source: sdk 64 | version: "0.0.0" 65 | flutter_test: 66 | dependency: "direct dev" 67 | description: flutter 68 | source: sdk 69 | version: "0.0.0" 70 | leak_tracker: 71 | dependency: transitive 72 | description: 73 | name: leak_tracker 74 | sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" 75 | url: "https://pub.dev" 76 | source: hosted 77 | version: "11.0.2" 78 | leak_tracker_flutter_testing: 79 | dependency: transitive 80 | description: 81 | name: leak_tracker_flutter_testing 82 | sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" 83 | url: "https://pub.dev" 84 | source: hosted 85 | version: "3.0.10" 86 | leak_tracker_testing: 87 | dependency: transitive 88 | description: 89 | name: leak_tracker_testing 90 | sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" 91 | url: "https://pub.dev" 92 | source: hosted 93 | version: "3.0.2" 94 | matcher: 95 | dependency: transitive 96 | description: 97 | name: matcher 98 | sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 99 | url: "https://pub.dev" 100 | source: hosted 101 | version: "0.12.17" 102 | material_color_utilities: 103 | dependency: transitive 104 | description: 105 | name: material_color_utilities 106 | sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec 107 | url: "https://pub.dev" 108 | source: hosted 109 | version: "0.11.1" 110 | meta: 111 | dependency: transitive 112 | description: 113 | name: meta 114 | sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c 115 | url: "https://pub.dev" 116 | source: hosted 117 | version: "1.16.0" 118 | path: 119 | dependency: transitive 120 | description: 121 | name: path 122 | sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" 123 | url: "https://pub.dev" 124 | source: hosted 125 | version: "1.9.1" 126 | sky_engine: 127 | dependency: transitive 128 | description: flutter 129 | source: sdk 130 | version: "0.0.0" 131 | source_span: 132 | dependency: transitive 133 | description: 134 | name: source_span 135 | sha256: d5f89a9e52b36240a80282b3dc0667dd36e53459717bb17b8fb102d30496606a 136 | url: "https://pub.dev" 137 | source: hosted 138 | version: "1.8.1" 139 | stack_trace: 140 | dependency: transitive 141 | description: 142 | name: stack_trace 143 | sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" 144 | url: "https://pub.dev" 145 | source: hosted 146 | version: "1.12.1" 147 | stream_channel: 148 | dependency: transitive 149 | description: 150 | name: stream_channel 151 | sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" 152 | url: "https://pub.dev" 153 | source: hosted 154 | version: "2.1.4" 155 | string_scanner: 156 | dependency: transitive 157 | description: 158 | name: string_scanner 159 | sha256: dd11571b8a03f7cadcf91ec26a77e02bfbd6bbba2a512924d3116646b4198fc4 160 | url: "https://pub.dev" 161 | source: hosted 162 | version: "1.1.0" 163 | term_glyph: 164 | dependency: transitive 165 | description: 166 | name: term_glyph 167 | sha256: a88162591b02c1f3a3db3af8ce1ea2b374bd75a7bb8d5e353bcfbdc79d719830 168 | url: "https://pub.dev" 169 | source: hosted 170 | version: "1.2.0" 171 | test_api: 172 | dependency: transitive 173 | description: 174 | name: test_api 175 | sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" 176 | url: "https://pub.dev" 177 | source: hosted 178 | version: "0.7.6" 179 | vector_math: 180 | dependency: transitive 181 | description: 182 | name: vector_math 183 | sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b 184 | url: "https://pub.dev" 185 | source: hosted 186 | version: "2.2.0" 187 | vm_service: 188 | dependency: transitive 189 | description: 190 | name: vm_service 191 | sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" 192 | url: "https://pub.dev" 193 | source: hosted 194 | version: "15.0.2" 195 | sdks: 196 | dart: ">=3.8.0-0 <4.0.0" 197 | flutter: ">=3.18.0-18.0.pre.54" 198 | -------------------------------------------------------------------------------- /example/lib/mutliple_forest_graphview.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:graphview/GraphView.dart'; 5 | 6 | class MultipleForestTreeViewPage extends StatefulWidget { 7 | @override 8 | _TreeViewPageState createState() => _TreeViewPageState(); 9 | } 10 | 11 | class _TreeViewPageState extends State with TickerProviderStateMixin { 12 | 13 | GraphViewController _controller = GraphViewController(); 14 | final Random r = Random(); 15 | int nextNodeId = 1; 16 | 17 | @override 18 | Widget build(BuildContext context) { 19 | return Scaffold( 20 | appBar: AppBar( 21 | title: Text('Tree View'), 22 | ), 23 | body: Column( 24 | mainAxisSize: MainAxisSize.max, 25 | children: [ 26 | // Configuration controls 27 | Wrap( 28 | children: [ 29 | Container( 30 | width: 100, 31 | child: TextFormField( 32 | initialValue: builder.siblingSeparation.toString(), 33 | decoration: InputDecoration(labelText: 'Sibling Separation'), 34 | onChanged: (text) { 35 | builder.siblingSeparation = int.tryParse(text) ?? 100; 36 | this.setState(() {}); 37 | }, 38 | ), 39 | ), 40 | Container( 41 | width: 100, 42 | child: TextFormField( 43 | initialValue: builder.levelSeparation.toString(), 44 | decoration: InputDecoration(labelText: 'Level Separation'), 45 | onChanged: (text) { 46 | builder.levelSeparation = int.tryParse(text) ?? 100; 47 | this.setState(() {}); 48 | }, 49 | ), 50 | ), 51 | Container( 52 | width: 100, 53 | child: TextFormField( 54 | initialValue: builder.subtreeSeparation.toString(), 55 | decoration: InputDecoration(labelText: 'Subtree separation'), 56 | onChanged: (text) { 57 | builder.subtreeSeparation = int.tryParse(text) ?? 100; 58 | this.setState(() {}); 59 | }, 60 | ), 61 | ), 62 | Container( 63 | width: 100, 64 | child: TextFormField( 65 | initialValue: builder.orientation.toString(), 66 | decoration: InputDecoration(labelText: 'Orientation'), 67 | onChanged: (text) { 68 | builder.orientation = int.tryParse(text) ?? 100; 69 | this.setState(() {}); 70 | }, 71 | ), 72 | ), 73 | ElevatedButton( 74 | onPressed: () { 75 | final node12 = Node.Id(r.nextInt(100)); 76 | var edge = graph.getNodeAtPosition(r.nextInt(graph.nodeCount())); 77 | print(edge); 78 | graph.addEdge(edge, node12); 79 | setState(() {}); 80 | }, 81 | child: Text('Add'), 82 | ), 83 | ElevatedButton( 84 | onPressed: _navigateToRandomNode, 85 | child: Text('Go to Node $nextNodeId'), 86 | ), 87 | SizedBox(width: 8), 88 | ElevatedButton( 89 | onPressed: _resetView, 90 | child: Text('Reset View'), 91 | ), 92 | SizedBox(width: 8,), 93 | ElevatedButton(onPressed: (){ 94 | _controller.zoomToFit(); 95 | }, child: Text('Zoom to fit')) 96 | ], 97 | ), 98 | 99 | Expanded( 100 | child: GraphView.builder( 101 | controller: _controller, 102 | graph: graph, 103 | algorithm: TidierTreeLayoutAlgorithm(builder, null), 104 | builder: (Node node) => Container( 105 | padding: EdgeInsets.all(8), 106 | decoration: BoxDecoration( 107 | color: Colors.lightBlue[100], 108 | borderRadius: BorderRadius.circular(8), 109 | ), 110 | child: Text(node.key?.value.toString() ?? ''), 111 | ), 112 | ) 113 | ), 114 | ], 115 | )); 116 | } 117 | 118 | Widget rectangleWidget(int? a) { 119 | return InkWell( 120 | onTap: () { 121 | print('clicked node $a'); 122 | }, 123 | child: Container( 124 | padding: EdgeInsets.all(16), 125 | decoration: BoxDecoration( 126 | borderRadius: BorderRadius.circular(4), 127 | boxShadow: [ 128 | BoxShadow(color: Colors.blue[100]!, spreadRadius: 1), 129 | ], 130 | ), 131 | child: Text('Node ${a} ')), 132 | ); 133 | } 134 | 135 | final Graph graph = Graph()..isTree = true; 136 | BuchheimWalkerConfiguration builder = BuchheimWalkerConfiguration(); 137 | 138 | void _navigateToRandomNode() { 139 | if (graph.nodes.isEmpty) return; 140 | 141 | final randomNode = graph.nodes.firstWhere( 142 | (node) => node.key != null && node.key!.value == nextNodeId, 143 | orElse: () => graph.nodes.first, 144 | ); 145 | final nodeId = randomNode.key!; 146 | _controller.animateToNode(nodeId); 147 | 148 | setState(() { 149 | nextNodeId = r.nextInt(graph.nodes.length) + 1; 150 | }); 151 | } 152 | 153 | void _resetView() { 154 | _controller.resetView(); 155 | } 156 | 157 | @override 158 | void initState() { 159 | super.initState(); 160 | 161 | var json = { 162 | 'edges': [ 163 | {'from': 1, 'to': 2}, 164 | {'from': 9, 'to': 2}, 165 | {'from': 10, 'to': 2}, 166 | {'from': 2, 'to': 3}, 167 | {'from': 2, 'to': 4}, 168 | {'from': 2, 'to': 5}, 169 | {'from': 5, 'to': 6}, 170 | {'from': 5, 'to': 7}, 171 | {'from': 6, 'to': 8}, 172 | {'from': 12, 'to': 11}, 173 | ] 174 | }; 175 | 176 | var edges = json['edges']!; 177 | edges.forEach((element) { 178 | var fromNodeId = element['from']; 179 | var toNodeId = element['to']; 180 | graph.addEdge(Node.Id(fromNodeId), Node.Id(toNodeId)); 181 | }); 182 | 183 | builder 184 | ..siblingSeparation = (100) 185 | ..levelSeparation = (150) 186 | ..subtreeSeparation = (150) 187 | ..orientation = (BuchheimWalkerConfiguration.ORIENTATION_TOP_BOTTOM); 188 | } 189 | 190 | } -------------------------------------------------------------------------------- /lib/edgerenderer/EdgeRenderer.dart: -------------------------------------------------------------------------------- 1 | part of graphview; 2 | 3 | abstract class EdgeRenderer { 4 | Map? _animatedPositions; 5 | 6 | void setAnimatedPositions(Map positions) => _animatedPositions = positions; 7 | 8 | Offset getNodePosition(Node node) => _animatedPositions?[node] ?? node.position; 9 | 10 | void renderEdge(Canvas canvas, Edge edge, Paint paint); 11 | 12 | Offset getNodeCenter(Node node) { 13 | final nodePosition = getNodePosition(node); 14 | return Offset( 15 | nodePosition.dx + node.width * 0.5, 16 | nodePosition.dy + node.height * 0.5, 17 | ); 18 | } 19 | 20 | /// Draws a line between two points respecting the node's line type 21 | void drawStyledLine(Canvas canvas, Offset start, Offset end, Paint paint, 22 | {LineType? lineType}) { 23 | switch (lineType) { 24 | case LineType.DashedLine: 25 | drawDashedLine(canvas, start, end, paint, 0.6); 26 | break; 27 | case LineType.DottedLine: 28 | drawDashedLine(canvas, start, end, paint, 0.0); 29 | break; 30 | case LineType.SineLine: 31 | drawSineLine(canvas, start, end, paint); 32 | break; 33 | default: 34 | canvas.drawLine(start, end, paint); 35 | break; 36 | } 37 | } 38 | 39 | /// Draws a styled path respecting the node's line type 40 | void drawStyledPath(Canvas canvas, Path path, Paint paint, 41 | {LineType? lineType}) { 42 | if (lineType == null || lineType == LineType.Default) { 43 | canvas.drawPath(path, paint); 44 | } else { 45 | // For non-solid lines, we need to convert the path to segments 46 | // This is a simplified approach - for complex paths with curves, 47 | // you might need a more sophisticated solution 48 | canvas.drawPath(path, paint); 49 | } 50 | } 51 | 52 | /// Draws a dashed line between two points 53 | void drawDashedLine(Canvas canvas, Offset source, Offset destination, 54 | Paint paint, double lineLength) { 55 | final dx = destination.dx - source.dx; 56 | final dy = destination.dy - source.dy; 57 | final distance = sqrt(dx * dx + dy * dy); 58 | 59 | if (distance == 0) return; 60 | 61 | final numLines = lineLength == 0.0 ? (distance / 5).ceil() : 14; 62 | final stepX = dx / numLines; 63 | final stepY = dy / numLines; 64 | 65 | if (lineLength == 0.0) { 66 | // Draw dots 67 | final circleRadius = 1.0; 68 | final circlePaint = Paint() 69 | ..color = paint.color 70 | ..strokeWidth = 1.0 71 | ..style = PaintingStyle.fill; 72 | 73 | for (var i = 0; i < numLines; i++) { 74 | final x = source.dx + (i * stepX); 75 | final y = source.dy + (i * stepY); 76 | canvas.drawCircle(Offset(x, y), circleRadius, circlePaint); 77 | } 78 | } else { 79 | // Draw dashes 80 | for (var i = 0; i < numLines; i++) { 81 | final startX = source.dx + (i * stepX); 82 | final startY = source.dy + (i * stepY); 83 | final endX = startX + (stepX * lineLength); 84 | final endY = startY + (stepY * lineLength); 85 | canvas.drawLine(Offset(startX, startY), Offset(endX, endY), paint); 86 | } 87 | } 88 | } 89 | 90 | /// Draws a sine wave line between two points 91 | void drawSineLine(Canvas canvas, Offset source, Offset destination, Paint paint) { 92 | final originalStrokeWidth = paint.strokeWidth; 93 | paint.strokeWidth = 1.5; 94 | 95 | final dx = destination.dx - source.dx; 96 | final dy = destination.dy - source.dy; 97 | final distance = sqrt(dx * dx + dy * dy); 98 | 99 | if (distance == 0 || (dx == 0 && dy == 0)) { 100 | paint.strokeWidth = originalStrokeWidth; 101 | return; 102 | } 103 | 104 | const lineLength = 6.0; 105 | const phaseOffset = 2.0; 106 | var distanceTraveled = 0.0; 107 | var phase = 0.0; 108 | 109 | final path = Path()..moveTo(source.dx, source.dy); 110 | 111 | while (distanceTraveled < distance) { 112 | final segmentLength = min(lineLength, distance - distanceTraveled); 113 | final segmentFraction = (distanceTraveled + segmentLength) / distance; 114 | final segmentDestination = Offset( 115 | source.dx + dx * segmentFraction, 116 | source.dy + dy * segmentFraction, 117 | ); 118 | 119 | final waveAmplitude = sin(phase + phaseOffset) * segmentLength; 120 | 121 | double perpX, perpY; 122 | if ((dx > 0 && dy < 0) || (dx < 0 && dy > 0)) { 123 | perpX = waveAmplitude; 124 | perpY = waveAmplitude; 125 | } else { 126 | perpX = -waveAmplitude; 127 | perpY = waveAmplitude; 128 | } 129 | 130 | path.lineTo(segmentDestination.dx + perpX, segmentDestination.dy + perpY); 131 | 132 | distanceTraveled += segmentLength; 133 | phase += pi * segmentLength / lineLength; 134 | } 135 | 136 | canvas.drawPath(path, paint); 137 | paint.strokeWidth = originalStrokeWidth; 138 | } 139 | 140 | /// Builds a loop path for self-referential edges and returns geometry 141 | /// data that renderers can use to draw arrows or style the segment. 142 | LoopRenderResult? buildSelfLoopPath( 143 | Edge edge, { 144 | double loopPadding = 16.0, 145 | double arrowLength = 12.0, 146 | }) { 147 | if (edge.source != edge.destination) { 148 | return null; 149 | } 150 | 151 | final node = edge.source; 152 | final nodeCenter = getNodeCenter(node); 153 | 154 | final anchorRadius = node.size.shortestSide * 0.5; 155 | 156 | final start = nodeCenter + Offset(anchorRadius, 0); 157 | 158 | final end = nodeCenter + Offset(0, -anchorRadius); 159 | 160 | final loopRadius = max( 161 | loopPadding + anchorRadius, 162 | anchorRadius * 1.5, 163 | ); 164 | 165 | final controlPoint1 = start + Offset(loopRadius, 0); 166 | 167 | final controlPoint2 = end + Offset(0, -loopRadius); 168 | 169 | final path = Path() 170 | ..moveTo(start.dx, start.dy) 171 | ..cubicTo( 172 | controlPoint1.dx, 173 | controlPoint1.dy, 174 | controlPoint2.dx, 175 | controlPoint2.dy, 176 | end.dx, 177 | end.dy, 178 | ); 179 | 180 | final metrics = path.computeMetrics().toList(); 181 | if (metrics.isEmpty) { 182 | return LoopRenderResult(path, start, end); 183 | } 184 | 185 | final metric = metrics.first; 186 | final totalLength = metric.length; 187 | final effectiveArrowLength = arrowLength <= 0 188 | ? 0.0 189 | : min(arrowLength, totalLength * 0.3); 190 | final arrowBaseOffset = max(0.0, totalLength - effectiveArrowLength); 191 | final arrowBaseTangent = metric.getTangentForOffset(arrowBaseOffset); 192 | final arrowTipTangent = metric.getTangentForOffset(totalLength); 193 | 194 | return LoopRenderResult( 195 | path, 196 | arrowBaseTangent?.position ?? end, 197 | arrowTipTangent?.position ?? end, 198 | ); 199 | } 200 | } 201 | 202 | class LoopRenderResult { 203 | final Path path; 204 | final Offset arrowBase; 205 | final Offset arrowTip; 206 | 207 | const LoopRenderResult(this.path, this.arrowBase, this.arrowTip); 208 | } 209 | -------------------------------------------------------------------------------- /example/pubspec.lock: -------------------------------------------------------------------------------- 1 | # Generated by pub 2 | # See https://dart.dev/tools/pub/glossary#lockfile 3 | packages: 4 | async: 5 | dependency: transitive 6 | description: 7 | name: async 8 | sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" 9 | url: "https://pub.dev" 10 | source: hosted 11 | version: "2.13.0" 12 | boolean_selector: 13 | dependency: transitive 14 | description: 15 | name: boolean_selector 16 | sha256: "5bbf32bc9e518d41ec49718e2931cd4527292c9b0c6d2dffcf7fe6b9a8a8cf72" 17 | url: "https://pub.dev" 18 | source: hosted 19 | version: "2.1.0" 20 | characters: 21 | dependency: transitive 22 | description: 23 | name: characters 24 | sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 25 | url: "https://pub.dev" 26 | source: hosted 27 | version: "1.4.0" 28 | charcode: 29 | dependency: transitive 30 | description: 31 | name: charcode 32 | sha256: fb98c0f6d12c920a02ee2d998da788bca066ca5f148492b7085ee23372b12306 33 | url: "https://pub.dev" 34 | source: hosted 35 | version: "1.3.1" 36 | clock: 37 | dependency: transitive 38 | description: 39 | name: clock 40 | sha256: fddb70d9b5277016c77a80201021d40a2247104d9f4aa7bab7157b7e3f05b84b 41 | url: "https://pub.dev" 42 | source: hosted 43 | version: "1.1.2" 44 | collection: 45 | dependency: transitive 46 | description: 47 | name: collection 48 | sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76" 49 | url: "https://pub.dev" 50 | source: hosted 51 | version: "1.19.1" 52 | fake_async: 53 | dependency: transitive 54 | description: 55 | name: fake_async 56 | sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44" 57 | url: "https://pub.dev" 58 | source: hosted 59 | version: "1.3.3" 60 | flutter: 61 | dependency: "direct main" 62 | description: flutter 63 | source: sdk 64 | version: "0.0.0" 65 | flutter_test: 66 | dependency: "direct dev" 67 | description: flutter 68 | source: sdk 69 | version: "0.0.0" 70 | graphview: 71 | dependency: "direct main" 72 | description: 73 | path: ".." 74 | relative: true 75 | source: path 76 | version: "1.5.1" 77 | leak_tracker: 78 | dependency: transitive 79 | description: 80 | name: leak_tracker 81 | sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de" 82 | url: "https://pub.dev" 83 | source: hosted 84 | version: "11.0.2" 85 | leak_tracker_flutter_testing: 86 | dependency: transitive 87 | description: 88 | name: leak_tracker_flutter_testing 89 | sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1" 90 | url: "https://pub.dev" 91 | source: hosted 92 | version: "3.0.10" 93 | leak_tracker_testing: 94 | dependency: transitive 95 | description: 96 | name: leak_tracker_testing 97 | sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1" 98 | url: "https://pub.dev" 99 | source: hosted 100 | version: "3.0.2" 101 | matcher: 102 | dependency: transitive 103 | description: 104 | name: matcher 105 | sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 106 | url: "https://pub.dev" 107 | source: hosted 108 | version: "0.12.17" 109 | material_color_utilities: 110 | dependency: transitive 111 | description: 112 | name: material_color_utilities 113 | sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec 114 | url: "https://pub.dev" 115 | source: hosted 116 | version: "0.11.1" 117 | meta: 118 | dependency: transitive 119 | description: 120 | name: meta 121 | sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c 122 | url: "https://pub.dev" 123 | source: hosted 124 | version: "1.16.0" 125 | nested: 126 | dependency: transitive 127 | description: 128 | name: nested 129 | sha256: "03bac4c528c64c95c722ec99280375a6f2fc708eec17c7b3f07253b626cd2a20" 130 | url: "https://pub.dev" 131 | source: hosted 132 | version: "1.0.0" 133 | path: 134 | dependency: transitive 135 | description: 136 | name: path 137 | sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5" 138 | url: "https://pub.dev" 139 | source: hosted 140 | version: "1.9.1" 141 | provider: 142 | dependency: "direct main" 143 | description: 144 | name: provider 145 | sha256: "4e82183fa20e5ca25703ead7e05de9e4cceed1fbd1eadc1ac3cb6f565a09f272" 146 | url: "https://pub.dev" 147 | source: hosted 148 | version: "6.1.5+1" 149 | sky_engine: 150 | dependency: transitive 151 | description: flutter 152 | source: sdk 153 | version: "0.0.0" 154 | source_span: 155 | dependency: transitive 156 | description: 157 | name: source_span 158 | sha256: d5f89a9e52b36240a80282b3dc0667dd36e53459717bb17b8fb102d30496606a 159 | url: "https://pub.dev" 160 | source: hosted 161 | version: "1.8.1" 162 | stack_trace: 163 | dependency: transitive 164 | description: 165 | name: stack_trace 166 | sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" 167 | url: "https://pub.dev" 168 | source: hosted 169 | version: "1.12.1" 170 | stream_channel: 171 | dependency: transitive 172 | description: 173 | name: stream_channel 174 | sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d" 175 | url: "https://pub.dev" 176 | source: hosted 177 | version: "2.1.4" 178 | string_scanner: 179 | dependency: transitive 180 | description: 181 | name: string_scanner 182 | sha256: dd11571b8a03f7cadcf91ec26a77e02bfbd6bbba2a512924d3116646b4198fc4 183 | url: "https://pub.dev" 184 | source: hosted 185 | version: "1.1.0" 186 | term_glyph: 187 | dependency: transitive 188 | description: 189 | name: term_glyph 190 | sha256: a88162591b02c1f3a3db3af8ce1ea2b374bd75a7bb8d5e353bcfbdc79d719830 191 | url: "https://pub.dev" 192 | source: hosted 193 | version: "1.2.0" 194 | test_api: 195 | dependency: transitive 196 | description: 197 | name: test_api 198 | sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" 199 | url: "https://pub.dev" 200 | source: hosted 201 | version: "0.7.6" 202 | vector_math: 203 | dependency: transitive 204 | description: 205 | name: vector_math 206 | sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b 207 | url: "https://pub.dev" 208 | source: hosted 209 | version: "2.2.0" 210 | vm_service: 211 | dependency: transitive 212 | description: 213 | name: vm_service 214 | sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" 215 | url: "https://pub.dev" 216 | source: hosted 217 | version: "15.0.2" 218 | sdks: 219 | dart: ">=3.8.0-0 <4.0.0" 220 | flutter: ">=3.18.0-18.0.pre.54" 221 | -------------------------------------------------------------------------------- /example/lib/large_tree_graphview.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:graphview/GraphView.dart'; 5 | 6 | class LargeTreeViewPage extends StatefulWidget { 7 | @override 8 | _LargeTreeViewPageState createState() => _LargeTreeViewPageState(); 9 | } 10 | 11 | class _LargeTreeViewPageState extends State with TickerProviderStateMixin { 12 | 13 | GraphViewController _controller = GraphViewController(); 14 | final Random r = Random(); 15 | int nextNodeId = 1; 16 | 17 | @override 18 | Widget build(BuildContext context) { 19 | return Scaffold( 20 | appBar: AppBar( 21 | title: Text('Tree View'), 22 | ), 23 | body: Column( 24 | mainAxisSize: MainAxisSize.max, 25 | children: [ 26 | // Configuration controls 27 | Wrap( 28 | children: [ 29 | Container( 30 | width: 100, 31 | child: TextFormField( 32 | initialValue: builder.siblingSeparation.toString(), 33 | decoration: InputDecoration(labelText: 'Sibling Separation'), 34 | onChanged: (text) { 35 | builder.siblingSeparation = int.tryParse(text) ?? 100; 36 | this.setState(() {}); 37 | }, 38 | ), 39 | ), 40 | Container( 41 | width: 100, 42 | child: TextFormField( 43 | initialValue: builder.levelSeparation.toString(), 44 | decoration: InputDecoration(labelText: 'Level Separation'), 45 | onChanged: (text) { 46 | builder.levelSeparation = int.tryParse(text) ?? 100; 47 | this.setState(() {}); 48 | }, 49 | ), 50 | ), 51 | Container( 52 | width: 100, 53 | child: TextFormField( 54 | initialValue: builder.subtreeSeparation.toString(), 55 | decoration: InputDecoration(labelText: 'Subtree separation'), 56 | onChanged: (text) { 57 | builder.subtreeSeparation = int.tryParse(text) ?? 100; 58 | this.setState(() {}); 59 | }, 60 | ), 61 | ), 62 | Container( 63 | width: 100, 64 | child: TextFormField( 65 | initialValue: builder.orientation.toString(), 66 | decoration: InputDecoration(labelText: 'Orientation'), 67 | onChanged: (text) { 68 | builder.orientation = int.tryParse(text) ?? 100; 69 | this.setState(() {}); 70 | }, 71 | ), 72 | ), 73 | ElevatedButton( 74 | onPressed: () { 75 | final node12 = Node.Id(r.nextInt(100)); 76 | var edge = graph.getNodeAtPosition(r.nextInt(graph.nodeCount())); 77 | print(edge); 78 | graph.addEdge(edge, node12); 79 | setState(() {}); 80 | }, 81 | child: Text('Add'), 82 | ), 83 | ElevatedButton( 84 | onPressed: _navigateToRandomNode, 85 | child: Text('Go to Node $nextNodeId'), 86 | ), 87 | SizedBox(width: 8), 88 | ElevatedButton( 89 | onPressed: _resetView, 90 | child: Text('Reset View'), 91 | ), 92 | SizedBox(width: 8,), 93 | ElevatedButton(onPressed: (){ 94 | _controller.zoomToFit(); 95 | }, child: Text('Zoom to fit')) 96 | ], 97 | ), 98 | 99 | Expanded( 100 | child: GraphView.builder( 101 | controller: _controller, 102 | graph: graph, 103 | algorithm: algorithm, 104 | centerGraph: true, 105 | initialNode: ValueKey(1), 106 | panAnimationDuration: Duration(milliseconds: 750), 107 | toggleAnimationDuration: Duration(milliseconds: 750), 108 | // edgeBuilder: (Edge edge, EdgeGeometry geometry) { 109 | // return InteractiveEdge( 110 | // edge: edge, 111 | // geometry: geometry, 112 | // onTap: () => print('Edge tapped: ${edge.key}'), 113 | // color: Colors.red, 114 | // strokeWidth: 3.0, 115 | // ); 116 | // }, 117 | builder: (Node node) => InkWell( 118 | onTap: () => _toggleCollapse(node), 119 | child: Container( 120 | padding: EdgeInsets.all(16), 121 | decoration: BoxDecoration( 122 | shape: BoxShape.circle, 123 | boxShadow: [BoxShadow(color: Colors.blue[100]!, spreadRadius: 1)], 124 | ), 125 | child: Text( 126 | '${node.key?.value}', 127 | ), 128 | ), 129 | ), 130 | ), 131 | ), 132 | ], 133 | )); 134 | } 135 | 136 | final Graph graph = Graph()..isTree = true; 137 | BuchheimWalkerConfiguration builder = BuchheimWalkerConfiguration(); 138 | late final algorithm = BuchheimWalkerAlgorithm(builder, TreeEdgeRenderer(builder)); 139 | 140 | void _toggleCollapse(Node node) { 141 | _controller.toggleNodeExpanded(graph, node, animate: true); 142 | } 143 | 144 | void _navigateToRandomNode() { 145 | if (graph.nodes.isEmpty) return; 146 | 147 | final randomNode = graph.nodes.firstWhere( 148 | (node) => node.key != null && node.key!.value == nextNodeId, 149 | orElse: () => graph.nodes.first, 150 | ); 151 | final nodeId = randomNode.key!; 152 | _controller.animateToNode(nodeId); 153 | 154 | setState(() { 155 | // nextNodeId = r.nextInt(graph.nodes.length) + 1; 156 | }); 157 | } 158 | 159 | void _resetView() { 160 | _controller.animateToNode(ValueKey(1)); 161 | } 162 | 163 | @override 164 | void initState() { 165 | super.initState(); 166 | 167 | var n = 1000; 168 | final nodes = List.generate(n, (i) => Node.Id(i + 1)); 169 | 170 | // Generate tree edges using a queue-based approach 171 | int currentChild = 1; // Start from node 1 (node 0 is root) 172 | 173 | for (var i = 0; i < n && currentChild < n; i++) { 174 | final children = (i < n ~/ 3) ? 3 : 2; 175 | 176 | for (var j = 0; j < children && currentChild < n; j++) { 177 | graph.addEdge(nodes[i], nodes[currentChild]); 178 | currentChild++; 179 | } 180 | } 181 | 182 | builder 183 | ..siblingSeparation = (10) 184 | ..levelSeparation = (100) 185 | ..subtreeSeparation = (10) 186 | ..useCurvedConnections = true 187 | ..orientation = (BuchheimWalkerConfiguration.ORIENTATION_LEFT_RIGHT); 188 | } 189 | 190 | } -------------------------------------------------------------------------------- /example/lib/layer_graphview_json.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:graphview/GraphView.dart'; 5 | 6 | class LayerGraphPageFromJson extends StatefulWidget { 7 | @override 8 | _LayerGraphPageFromJsonState createState() => _LayerGraphPageFromJsonState(); 9 | } 10 | 11 | class _LayerGraphPageFromJsonState extends State { 12 | var json = { 13 | 'edges': [ 14 | { 15 | 'from': '1', 16 | 'to': '2' 17 | }, 18 | { 19 | 'from': '3', 20 | 'to': '2' 21 | }, 22 | { 23 | 'from': '4', 24 | 'to': '5' 25 | }, 26 | { 27 | 'from': '6', 28 | 'to': '4' 29 | }, 30 | { 31 | 'from': '2', 32 | 'to': '4' 33 | }, 34 | { 35 | 'from': '2', 36 | 'to': '7' 37 | }, 38 | { 39 | 'from': '2', 40 | 'to': '8' 41 | }, 42 | { 43 | 'from': '9', 44 | 'to': '10' 45 | }, 46 | { 47 | 'from': '9', 48 | 'to': '11' 49 | }, 50 | { 51 | 'from': '5', 52 | 'to': '12' 53 | }, 54 | { 55 | 'from': '4', 56 | 'to': '9' 57 | }, 58 | { 59 | 'from': '6', 60 | 'to': '13' 61 | }, 62 | { 63 | 'from': '6', 64 | 'to': '14' 65 | }, 66 | { 67 | 'from': '6', 68 | 'to': '15' 69 | }, 70 | { 71 | 'from': '16', 72 | 'to': '3' 73 | }, 74 | { 75 | 'from': '17', 76 | 'to': '3' 77 | }, 78 | { 79 | 'from': '18', 80 | 'to': '16' 81 | }, 82 | { 83 | 'from': '19', 84 | 'to': '17' 85 | }, 86 | { 87 | 'from': '11', 88 | 'to': '1' 89 | }, 90 | 91 | ] 92 | }; 93 | 94 | GraphViewController _controller = GraphViewController(); 95 | final Random r = Random(); 96 | int nextNodeId = 0; 97 | 98 | @override 99 | Widget build(BuildContext context) { 100 | return Scaffold( 101 | appBar: AppBar(), 102 | body: Column( 103 | mainAxisSize: MainAxisSize.max, 104 | children: [ 105 | Wrap( 106 | children: [ 107 | Container( 108 | width: 100, 109 | child: TextFormField( 110 | initialValue: builder.nodeSeparation.toString(), 111 | decoration: InputDecoration(labelText: 'Node Separation'), 112 | onChanged: (text) { 113 | builder.nodeSeparation = int.tryParse(text) ?? 100; 114 | this.setState(() {}); 115 | }, 116 | ), 117 | ), 118 | Container( 119 | width: 100, 120 | child: TextFormField( 121 | initialValue: builder.levelSeparation.toString(), 122 | decoration: InputDecoration(labelText: 'Level Separation'), 123 | onChanged: (text) { 124 | builder.levelSeparation = int.tryParse(text) ?? 100; 125 | this.setState(() {}); 126 | }, 127 | ), 128 | ), 129 | Container( 130 | width: 100, 131 | child: TextFormField( 132 | initialValue: builder.orientation.toString(), 133 | decoration: InputDecoration(labelText: 'Orientation'), 134 | onChanged: (text) { 135 | builder.orientation = int.tryParse(text) ?? 100; 136 | this.setState(() {}); 137 | }, 138 | ), 139 | ), 140 | Container( 141 | width: 100, 142 | child: Column( 143 | children: [ 144 | Text('Alignment'), 145 | DropdownButton( 146 | value: builder.coordinateAssignment, 147 | items: CoordinateAssignment.values.map((coordinateAssignment) { 148 | return DropdownMenuItem( 149 | value: coordinateAssignment, 150 | child: Text(coordinateAssignment.name), 151 | ); 152 | }).toList(), 153 | onChanged: (value) { 154 | setState(() { 155 | builder.coordinateAssignment = value!; 156 | }); 157 | }, 158 | ), 159 | ], 160 | ), 161 | ), 162 | ElevatedButton( 163 | onPressed: () => _navigateToRandomNode(), 164 | child: Text('Go to Node $nextNodeId'), 165 | ), 166 | ElevatedButton( 167 | onPressed: () => _controller.resetView(), 168 | child: Text('Reset View'), 169 | ), 170 | ElevatedButton( 171 | onPressed: () => _controller.zoomToFit(), 172 | child: Text('Zoom to fit'), 173 | ), 174 | ], 175 | ), 176 | Expanded( 177 | child: GraphView.builder( 178 | controller: _controller, 179 | graph: graph, 180 | algorithm: SugiyamaAlgorithm(builder), 181 | paint: Paint() 182 | ..color = Colors.green 183 | ..strokeWidth = 1 184 | ..style = PaintingStyle.stroke, 185 | builder: (Node node) { 186 | // I can decide what widget should be shown here based on the id 187 | var a = node.key!.value; 188 | return rectangleWidget(a, node); 189 | }, 190 | ), 191 | ), 192 | ], 193 | )); 194 | } 195 | 196 | void _navigateToRandomNode() { 197 | if (graph.nodes.isEmpty) return; 198 | 199 | final randomNode = graph.nodes.firstWhere( 200 | (node) => node.key != null && node.key!.value == nextNodeId, 201 | orElse: () => graph.nodes.first, 202 | ); 203 | final nodeId = randomNode.key!; 204 | _controller.animateToNode(nodeId); 205 | 206 | setState(() { 207 | nextNodeId = r.nextInt(graph.nodes.length) + 1; 208 | }); 209 | } 210 | 211 | 212 | Widget rectangleWidget(String? a, Node node) { 213 | return Container( 214 | color: Colors.amber, 215 | child: InkWell( 216 | onTap: () { 217 | print('clicked'); 218 | }, 219 | child: Container( 220 | padding: EdgeInsets.all(16), 221 | decoration: BoxDecoration( 222 | borderRadius: BorderRadius.circular(4), 223 | boxShadow: [ 224 | BoxShadow(color: Colors.blue[100]!, spreadRadius: 1), 225 | ], 226 | ), 227 | child: Text('${a}')), 228 | ), 229 | ); 230 | } 231 | 232 | final Graph graph = Graph(); 233 | @override 234 | void initState() { 235 | super.initState(); 236 | var edges = json['edges']!; 237 | edges.forEach((element) { 238 | var fromNodeId = element['from']; 239 | var toNodeId = element['to']; 240 | graph.addEdge(Node.Id(fromNodeId), Node.Id(toNodeId)); 241 | }); 242 | 243 | builder 244 | ..nodeSeparation = (15) 245 | ..levelSeparation = (15) 246 | ..orientation = SugiyamaConfiguration.ORIENTATION_TOP_BOTTOM; 247 | } 248 | 249 | } 250 | 251 | var builder = SugiyamaConfiguration(); 252 | 253 | -------------------------------------------------------------------------------- /lib/layered/SugiyamaEdgeRenderer.dart: -------------------------------------------------------------------------------- 1 | part of graphview; 2 | 3 | class SugiyamaEdgeRenderer extends ArrowEdgeRenderer { 4 | Map nodeData; 5 | Map edgeData; 6 | BendPointShape bendPointShape; 7 | bool addTriangleToEdge; 8 | var path = Path(); 9 | 10 | SugiyamaEdgeRenderer(this.nodeData, this.edgeData, this.bendPointShape, this.addTriangleToEdge); 11 | 12 | bool hasBendEdges(Edge edge) => edgeData.containsKey(edge) && edgeData[edge]!.bendPoints.isNotEmpty; 13 | 14 | void render(Canvas canvas, Graph graph, Paint paint) { 15 | graph.edges.forEach((edge) { 16 | renderEdge(canvas, edge, paint); 17 | }); 18 | } 19 | 20 | @override 21 | void renderEdge(Canvas canvas, Edge edge, Paint paint) { 22 | var trianglePaint = Paint() 23 | ..color = paint.color 24 | ..style = PaintingStyle.fill; 25 | 26 | Paint? edgeTrianglePaint; 27 | if (edge.paint != null) { 28 | edgeTrianglePaint = Paint() 29 | ..color = edge.paint?.color ?? paint.color 30 | ..style = PaintingStyle.fill; 31 | } 32 | 33 | var currentPaint = (edge.paint ?? paint) 34 | ..style = PaintingStyle.stroke; 35 | 36 | if (edge.source == edge.destination) { 37 | final loopResult = buildSelfLoopPath( 38 | edge, 39 | arrowLength: addTriangleToEdge ? ARROW_LENGTH : 0.0, 40 | ); 41 | 42 | if (loopResult != null) { 43 | final lineType = nodeData[edge.destination]?.lineType; 44 | drawStyledPath(canvas, loopResult.path, currentPaint, lineType: lineType); 45 | 46 | if (addTriangleToEdge) { 47 | final triangleCentroid = drawTriangle( 48 | canvas, 49 | edgeTrianglePaint ?? trianglePaint, 50 | loopResult.arrowBase.dx, 51 | loopResult.arrowBase.dy, 52 | loopResult.arrowTip.dx, 53 | loopResult.arrowTip.dy, 54 | ); 55 | 56 | drawStyledLine( 57 | canvas, 58 | loopResult.arrowBase, 59 | triangleCentroid, 60 | currentPaint, 61 | lineType: lineType, 62 | ); 63 | } 64 | 65 | return; 66 | } 67 | } 68 | 69 | if (hasBendEdges(edge)) { 70 | _renderEdgeWithBendPoints(canvas, edge, currentPaint, edgeTrianglePaint ?? trianglePaint); 71 | } else { 72 | _renderStraightEdge(canvas, edge, currentPaint, edgeTrianglePaint ?? trianglePaint); 73 | } 74 | } 75 | 76 | void _renderEdgeWithBendPoints(Canvas canvas, Edge edge, Paint currentPaint, Paint trianglePaint) { 77 | final source = edge.source; 78 | final destination = edge.destination; 79 | var bendPoints = edgeData[edge]!.bendPoints; 80 | 81 | var sourceCenter = _getNodeCenter(source); 82 | 83 | // Calculate the transition/offset from the original bend point to animated position 84 | final transitionDx = sourceCenter.dx - bendPoints[0]; 85 | final transitionDy = sourceCenter.dy - bendPoints[1]; 86 | 87 | path.reset(); 88 | path.moveTo(sourceCenter.dx, sourceCenter.dy); 89 | 90 | final bendPointsWithoutDuplication = []; 91 | 92 | for (var i = 0; i < bendPoints.length; i += 2) { 93 | final isLastPoint = i == bendPoints.length - 2; 94 | 95 | // Apply the same transition to all bend points 96 | final x = bendPoints[i] + transitionDx; 97 | final y = bendPoints[i + 1] + transitionDy; 98 | final x2 = isLastPoint ? -1 : bendPoints[i + 2] + transitionDx; 99 | final y2 = isLastPoint ? -1 : bendPoints[i + 3] + transitionDy; 100 | 101 | if (x == x2 && y == y2) { 102 | // Skip when two consecutive points are identical 103 | // because drawing a line between would be redundant in this case. 104 | continue; 105 | } 106 | bendPointsWithoutDuplication.add(Offset(x, y)); 107 | } 108 | 109 | if (bendPointShape is MaxCurvedBendPointShape) { 110 | _drawMaxCurvedBendPointsEdge(bendPointsWithoutDuplication); 111 | } else if (bendPointShape is CurvedBendPointShape) { 112 | final shape = bendPointShape as CurvedBendPointShape; 113 | _drawCurvedBendPointsEdge(bendPointsWithoutDuplication, shape.curveLength); 114 | } else { 115 | _drawSharpBendPointsEdge(bendPointsWithoutDuplication); 116 | } 117 | 118 | var descOffset = getNodePosition(destination); 119 | var stopX = descOffset.dx + destination.width * 0.5; 120 | var stopY = descOffset.dy + destination.height * 0.5; 121 | 122 | if (addTriangleToEdge) { 123 | var clippedLine = []; 124 | final size = bendPoints.length; 125 | if (nodeData[source]!.isReversed) { 126 | clippedLine = clipLineEnd(bendPoints[2], bendPoints[3], stopX, stopY, destination.x, 127 | destination.y, destination.width, destination.height); 128 | } else { 129 | clippedLine = clipLineEnd(bendPoints[size - 4], bendPoints[size - 3], 130 | stopX, stopY, descOffset.dx, 131 | descOffset.dy, destination.width, destination.height); 132 | } 133 | final triangleCentroid = drawTriangle(canvas, trianglePaint, clippedLine[0], clippedLine[1], clippedLine[2], clippedLine[3]); 134 | path.lineTo(triangleCentroid.dx, triangleCentroid.dy); 135 | } else { 136 | path.lineTo(stopX, stopY); 137 | } 138 | canvas.drawPath(path, currentPaint); 139 | } 140 | 141 | void _renderStraightEdge(Canvas canvas, Edge edge, Paint currentPaint, Paint trianglePaint) { 142 | final source = edge.source; 143 | final destination = edge.destination; 144 | final sourceCenter = _getNodeCenter(source); 145 | var destCenter = _getNodeCenter(destination); 146 | 147 | if (addTriangleToEdge) { 148 | final clippedLine = clipLineEnd(sourceCenter.dx, sourceCenter.dy, 149 | destCenter.dx, destCenter.dy, destination.x, 150 | destination.y, destination.width, destination.height); 151 | 152 | destCenter = drawTriangle(canvas, trianglePaint, clippedLine[0], clippedLine[1], clippedLine[2], clippedLine[3]); 153 | } 154 | 155 | // Draw the line with appropriate line type using the base class method 156 | final lineType = nodeData[destination]?.lineType; 157 | drawStyledLine(canvas, sourceCenter, destCenter, currentPaint, lineType: lineType); 158 | } 159 | 160 | void _drawSharpBendPointsEdge(List bendPoints) { 161 | for (var i = 1; i < bendPoints.length - 1; i++) { 162 | path.lineTo(bendPoints[i].dx, bendPoints[i].dy); 163 | } 164 | } 165 | 166 | void _drawMaxCurvedBendPointsEdge(List bendPoints) { 167 | for (var i = 1; i < bendPoints.length - 1; i++) { 168 | final nextNode = bendPoints[i]; 169 | final afterNextNode = bendPoints[i + 1]; 170 | final curveEndPoint = Offset((nextNode.dx + afterNextNode.dx) / 2, (nextNode.dy + afterNextNode.dy) / 2); 171 | path.quadraticBezierTo(nextNode.dx, nextNode.dy, curveEndPoint.dx, curveEndPoint.dy); 172 | } 173 | } 174 | 175 | void _drawCurvedBendPointsEdge(List bendPoints, double curveLength) { 176 | for (var i = 1; i < bendPoints.length - 1; i++) { 177 | final previousNode = i == 1 ? null : bendPoints[i - 2]; 178 | final currentNode = bendPoints[i - 1]; 179 | final nextNode = bendPoints[i]; 180 | final afterNextNode = bendPoints[i + 1]; 181 | 182 | final arcStartPointRadians = atan2(nextNode.dy - currentNode.dy, nextNode.dx - currentNode.dx); 183 | final arcStartPoint = nextNode - Offset.fromDirection(arcStartPointRadians, curveLength); 184 | final arcEndPointRadians = atan2(nextNode.dy - afterNextNode.dy, nextNode.dx - afterNextNode.dx); 185 | final arcEndPoint = nextNode - Offset.fromDirection(arcEndPointRadians, curveLength); 186 | 187 | if (previousNode != null && ((currentNode.dx == nextNode.dx && nextNode.dx == afterNextNode.dx) || (currentNode.dy == nextNode.dy && nextNode.dy == afterNextNode.dy))) { 188 | path.lineTo(nextNode.dx, nextNode.dy); 189 | } else { 190 | path.lineTo(arcStartPoint.dx, arcStartPoint.dy); 191 | path.quadraticBezierTo(nextNode.dx, nextNode.dy, arcEndPoint.dx, arcEndPoint.dy); 192 | } 193 | } 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /test/controller_tests.dart: -------------------------------------------------------------------------------- 1 | import 'package:flutter/material.dart'; 2 | import 'package:flutter_test/flutter_test.dart'; 3 | import 'package:graphview/GraphView.dart'; 4 | 5 | void main() { 6 | group('GraphView Controller Tests', () { 7 | testWidgets('animateToNode centers the target node', 8 | (WidgetTester tester) async { 9 | // Setup graph 10 | final graph = Graph(); 11 | final targetNode = Node.Id('target'); 12 | targetNode.key = const ValueKey('target'); 13 | final otherNode = Node.Id('other'); 14 | 15 | graph.addEdge(targetNode, otherNode); 16 | 17 | final transformationController = TransformationController(); 18 | final controller = GraphViewController( 19 | transformationController: transformationController); 20 | final configuration = BuchheimWalkerConfiguration(); 21 | final algorithm = BuchheimWalkerAlgorithm( 22 | configuration, TreeEdgeRenderer(configuration)); 23 | 24 | // Build widget 25 | await tester.pumpWidget( 26 | MaterialApp( 27 | home: Scaffold( 28 | body: SizedBox( 29 | width: 400, 30 | height: 600, 31 | child: GraphView.builder( 32 | graph: graph, 33 | algorithm: algorithm, 34 | controller: controller, 35 | builder: (node) => Container( 36 | width: 100, 37 | height: 50, 38 | color: Colors.blue, 39 | child: Text(node.key?.value ?? ''), 40 | ), 41 | ), 42 | ), 43 | ), 44 | ), 45 | ); 46 | 47 | await tester.pumpAndSettle(); 48 | 49 | // Get the actual position of target node after algorithm runs 50 | final actualNodePosition = targetNode.position; 51 | final nodeCenter = Offset( 52 | actualNodePosition.dx + targetNode.width / 2, 53 | actualNodePosition.dy + targetNode.height / 2, 54 | ); 55 | 56 | // Get initial transformation 57 | final initialMatrix = transformationController.value; 58 | 59 | // Animate to target node 60 | controller.animateToNode(const ValueKey('target')); 61 | 62 | // Let animation complete 63 | await tester.pump(const Duration(milliseconds: 100)); 64 | await tester.pumpAndSettle(); 65 | 66 | // Verify transformation changed 67 | final finalMatrix = transformationController.value; 68 | expect(finalMatrix, isNot(equals(initialMatrix))); 69 | 70 | // With viewport size 400x600, center should be at (200, 300) 71 | // Expected translation should center the node at viewport center 72 | final expectedTranslationX = 73 | 200 - nodeCenter.dx; // viewport_center_x - node_center_x 74 | final expectedTranslationY = 75 | 300 - nodeCenter.dy; // viewport_center_y - node_center_y 76 | 77 | expect(finalMatrix.getTranslation().x, closeTo(expectedTranslationX, 5)); 78 | expect(finalMatrix.getTranslation().y, closeTo(expectedTranslationY, 5)); 79 | }); 80 | 81 | testWidgets('animateToNode handles non-existent node gracefully', 82 | (WidgetTester tester) async { 83 | final graph = Graph(); 84 | final node = Node.Id('exists'); 85 | graph.nodes.add(node); 86 | 87 | final transformationController = TransformationController(); 88 | final controller = GraphViewController( 89 | transformationController: transformationController); 90 | final algorithm = BuchheimWalkerAlgorithm(BuchheimWalkerConfiguration(), 91 | TreeEdgeRenderer(BuchheimWalkerConfiguration())); 92 | 93 | await tester.pumpWidget( 94 | MaterialApp( 95 | home: GraphView.builder( 96 | graph: graph, 97 | algorithm: algorithm, 98 | controller: controller, 99 | builder: (node) => Container(), 100 | ), 101 | ), 102 | ); 103 | 104 | await tester.pumpAndSettle(); 105 | 106 | final initialMatrix = transformationController.value; 107 | 108 | // Try to animate to non-existent node 109 | controller.animateToNode(const ValueKey('nonexistent')); 110 | await tester.pumpAndSettle(); 111 | 112 | // Matrix should remain unchanged 113 | final finalMatrix = transformationController.value; 114 | expect(finalMatrix, equals(initialMatrix)); 115 | }); 116 | }); 117 | 118 | group('Collapse Tests', () { 119 | late Graph graph; 120 | late GraphViewController controller; 121 | 122 | setUp(() { 123 | graph = Graph(); 124 | controller = GraphViewController(); 125 | }); 126 | 127 | // Helper function to create a graph with multiple branches 128 | Graph createComplexGraph() { 129 | final g = Graph(); 130 | 131 | final root = Node.Id(0); 132 | final branch1 = Node.Id(1); 133 | final branch2 = Node.Id(2); 134 | final leaf1 = Node.Id(3); 135 | final leaf2 = Node.Id(4); 136 | final leaf3 = Node.Id(5); 137 | final leaf4 = Node.Id(6); 138 | 139 | g.addEdge(root, branch1); 140 | g.addEdge(root, branch2); 141 | g.addEdge(branch1, leaf1); 142 | g.addEdge(branch1, leaf2); 143 | g.addEdge(branch2, leaf3); 144 | g.addEdge(branch2, leaf4); 145 | 146 | return g; 147 | } 148 | 149 | test('Complex graph - multiple branches', () { 150 | final g = createComplexGraph(); 151 | final root = g.getNodeAtPosition(0); 152 | 153 | controller.collapseNode(g, root); 154 | 155 | final edges = controller.getCollapsingEdges(g); 156 | 157 | // Should get all 6 edges (root->branch1, root->branch2, branch1->leaf1, branch1->leaf2, branch2->leaf3, branch2->leaf4) 158 | expect(edges.length, 6); 159 | }); 160 | 161 | test('Nested collapse preserves original hide relationships', () { 162 | final graph = Graph(); 163 | final parent = Node.Id(0); 164 | final child = Node.Id(1); 165 | final grandchild = Node.Id(2); 166 | 167 | graph.addEdge(parent, child); 168 | graph.addEdge(child, grandchild); 169 | 170 | final controller = GraphViewController(); 171 | 172 | // Step 1: Collapse child 173 | controller.collapseNode(graph, child); 174 | 175 | expect(controller.isNodeVisible(graph, parent), true); 176 | expect(controller.isNodeVisible(graph, child), true); 177 | expect(controller.isNodeVisible(graph, grandchild), false); 178 | expect( 179 | controller.hiddenBy[grandchild], child); // grandchild hidden by child 180 | 181 | // Step 2: Collapse parent 182 | controller.collapseNode(graph, parent); 183 | 184 | expect(controller.isNodeVisible(graph, parent), true); 185 | expect(controller.isNodeVisible(graph, child), false); 186 | expect(controller.isNodeVisible(graph, grandchild), false); 187 | expect(controller.hiddenBy[child], parent); // child hidden by parent 188 | expect(controller.hiddenBy[grandchild], 189 | child); // grandchild STILL hidden by child! 190 | 191 | // Step 3: Get collapsing edges for parent 192 | controller.collapsedNode = parent; 193 | final parentEdges = controller.getCollapsingEdges(graph); 194 | 195 | // Should only include parent -> child, NOT child -> grandchild 196 | expect(parentEdges.length, 1); 197 | expect(parentEdges.first.source, parent); 198 | expect(parentEdges.first.destination, child); 199 | 200 | // Step 4: Expand parent 201 | controller.expandNode(graph, parent); 202 | 203 | expect(controller.isNodeVisible(graph, parent), true); 204 | expect(controller.isNodeVisible(graph, child), true); 205 | expect( 206 | controller.isNodeVisible(graph, grandchild), false); // Still hidden! 207 | expect(controller.hiddenBy[grandchild], child); // Still hidden by child! 208 | 209 | // Step 5: Expand child 210 | controller.expandNode(graph, child); 211 | 212 | expect(controller.isNodeVisible(graph, parent), true); 213 | expect(controller.isNodeVisible(graph, child), true); 214 | expect(controller.isNodeVisible(graph, grandchild), true); // Now visible! 215 | expect(controller.hiddenBy.containsKey(grandchild), false); 216 | }); 217 | }); 218 | } 219 | -------------------------------------------------------------------------------- /lib/tree/BaloonLayoutAlgorithm.dart: -------------------------------------------------------------------------------- 1 | part of graphview; 2 | 3 | // Polar coordinate representation 4 | class PolarPoint { 5 | final double theta; // angle in radians 6 | final double radius; 7 | 8 | const PolarPoint(this.theta, this.radius); 9 | 10 | static const PolarPoint origin = PolarPoint(0, 0); 11 | 12 | // Convert polar coordinates to cartesian 13 | Offset toCartesian() { 14 | final x = radius * cos(theta); 15 | final y = radius * sin(theta); 16 | return Offset(x, y); 17 | } 18 | 19 | // Create polar point from angle and radius 20 | static PolarPoint of(double theta, double radius) { 21 | return PolarPoint(theta, radius); 22 | } 23 | 24 | @override 25 | String toString() => 'PolarPoint(theta: $theta, radius: $radius)'; 26 | } 27 | 28 | class BalloonLayoutAlgorithm extends Algorithm { 29 | late BuchheimWalkerConfiguration config; 30 | final Map nodeData = {}; 31 | final Map polarLocations = {}; 32 | final Map radii = {}; 33 | 34 | BalloonLayoutAlgorithm(this.config, EdgeRenderer? renderer) { 35 | this.renderer = renderer ?? ArrowEdgeRenderer(); 36 | } 37 | 38 | @override 39 | Size run(Graph? graph, double shiftX, double shiftY) { 40 | if (graph == null || graph.nodes.isEmpty) { 41 | return Size.zero; 42 | } 43 | 44 | nodeData.clear(); 45 | polarLocations.clear(); 46 | radii.clear(); 47 | 48 | // Handle single node case 49 | if (graph.nodes.length == 1) { 50 | final node = graph.nodes.first; 51 | node.position = Offset(shiftX + 100, shiftY + 100); 52 | return Size(200, 200); 53 | } 54 | 55 | _initializeData(graph); 56 | final roots = _findRoots(graph); 57 | 58 | if (roots.isEmpty) { 59 | final spanningTree = _createSpanningTree(graph); 60 | return _layoutSpanningTree(spanningTree, shiftX, shiftY); 61 | } 62 | 63 | _setRootPolars(graph, roots); 64 | _shiftCoordinates(graph, shiftX, shiftY); 65 | return graph.calculateGraphSize(); 66 | } 67 | 68 | void _initializeData(Graph graph) { 69 | // Initialize node data 70 | for (final node in graph.nodes) { 71 | nodeData[node] = TreeLayoutNodeData(); 72 | } 73 | 74 | // Build tree structure from edges 75 | for (final edge in graph.edges) { 76 | final source = edge.source; 77 | final target = edge.destination; 78 | 79 | nodeData[source]!.successorNodes.add(target); 80 | nodeData[target]!.parent = source; 81 | } 82 | } 83 | 84 | List _findRoots(Graph graph) { 85 | return graph.nodes.where((node) { 86 | return nodeData[node]!.parent == null; 87 | }).toList(); 88 | } 89 | 90 | void _setRootPolars(Graph graph, List roots) { 91 | final center = _getGraphCenter(graph); 92 | final width = graph.calculateGraphBounds().width; 93 | final defaultRadius = max(width / 2, 200.0); 94 | 95 | if (roots.length == 1) { 96 | // Single tree - place root at center 97 | final root = roots.first; 98 | _setRootPolar(root, center); 99 | final children = successorsOf(root); 100 | _setPolars(children, center, 0, defaultRadius, {}); 101 | } else if (roots.length > 1) { 102 | // Multiple trees - arrange roots in circle 103 | _setPolars(roots, center, 0, defaultRadius, {}); 104 | } 105 | } 106 | 107 | void _setRootPolar(Node root, Offset center) { 108 | polarLocations[root] = PolarPoint.origin; 109 | root.position = center; 110 | } 111 | 112 | void _setPolars(List nodes, Offset parentLocation, double angleToParent, 113 | double parentRadius, Set seen) { 114 | final childCount = nodes.length; 115 | if (childCount == 0) return; 116 | 117 | // Calculate child placement parameters 118 | final angle = max(0, pi / 2 * (1 - 2.0 / childCount)); 119 | final childRadius = parentRadius * cos(angle) / (1 + cos(angle)); 120 | final radius = parentRadius - childRadius; 121 | 122 | // Angle between children 123 | final angleBetweenKids = 2 * pi / childCount; 124 | final offset = angleBetweenKids / 2 - angleToParent; 125 | 126 | for (var i = 0; i < nodes.length; i++) { 127 | final child = nodes[i]; 128 | if (seen.contains(child)) continue; 129 | 130 | // Calculate angle for this child 131 | final theta = i * angleBetweenKids + offset; 132 | 133 | // Store radius and polar coordinates 134 | radii[child] = childRadius; 135 | final polarPoint = PolarPoint.of(theta, radius); 136 | polarLocations[child] = polarPoint; 137 | 138 | // Convert to cartesian and position node 139 | final cartesian = polarPoint.toCartesian(); 140 | final position = Offset( 141 | parentLocation.dx + cartesian.dx, 142 | parentLocation.dy + cartesian.dy, 143 | ); 144 | child.position = position; 145 | 146 | final newAngleToParent = atan2( 147 | parentLocation.dy - position.dy, 148 | parentLocation.dx - position.dx, 149 | ); 150 | 151 | final grandChildren = successorsOf(child) 152 | .where((node) => !seen.contains(node)) 153 | .toList(); 154 | 155 | if (grandChildren.isNotEmpty) { 156 | final newSeen = Set.from(seen); 157 | newSeen.add(child); // Add current child to prevent cycles 158 | _setPolars(grandChildren, position, newAngleToParent, childRadius, newSeen); 159 | } 160 | } 161 | } 162 | 163 | Offset _getGraphCenter(Graph graph) { 164 | final bounds = graph.calculateGraphBounds(); 165 | return Offset( 166 | bounds.left + bounds.width / 2, 167 | bounds.top + bounds.height / 2, 168 | ); 169 | } 170 | 171 | void _shiftCoordinates(Graph graph, double shiftX, double shiftY) { 172 | for (final node in graph.nodes) { 173 | node.position = Offset(node.x + shiftX, node.y + shiftY); 174 | } 175 | } 176 | 177 | Graph _createSpanningTree(Graph graph) { 178 | final visited = {}; 179 | final spanningEdges = []; 180 | 181 | if (graph.nodes.isNotEmpty) { 182 | final startNode = graph.nodes.first; 183 | final queue = [startNode]; 184 | visited.add(startNode); 185 | 186 | while (queue.isNotEmpty) { 187 | final current = queue.removeAt(0); 188 | 189 | for (final edge in graph.edges) { 190 | Node? neighbor; 191 | if (edge.source == current && !visited.contains(edge.destination)) { 192 | neighbor = edge.destination; 193 | spanningEdges.add(edge); 194 | } else if (edge.destination == current && !visited.contains(edge.source)) { 195 | neighbor = edge.source; 196 | spanningEdges.add(Edge(current, edge.source)); 197 | } 198 | 199 | if (neighbor != null && !visited.contains(neighbor)) { 200 | visited.add(neighbor); 201 | queue.add(neighbor); 202 | } 203 | } 204 | } 205 | } 206 | 207 | return Graph()..addEdges(spanningEdges); 208 | } 209 | 210 | Size _layoutSpanningTree(Graph spanningTree, double shiftX, double shiftY) { 211 | nodeData.clear(); 212 | polarLocations.clear(); 213 | radii.clear(); 214 | 215 | _initializeData(spanningTree); 216 | final roots = _findRoots(spanningTree); 217 | 218 | if (roots.isEmpty && spanningTree.nodes.isNotEmpty) { 219 | final fakeRoot = spanningTree.nodes.first; 220 | _setRootPolars(spanningTree, [fakeRoot]); 221 | } else { 222 | _setRootPolars(spanningTree, roots); 223 | } 224 | 225 | _shiftCoordinates(spanningTree, shiftX, shiftY); 226 | return spanningTree.calculateGraphSize(); 227 | } 228 | 229 | List successorsOf(Node? node) { 230 | return nodeData[node]!.successorNodes; 231 | } 232 | 233 | PolarPoint? getPolarLocation(Node node) { 234 | return polarLocations[node]; 235 | } 236 | 237 | double? getRadius(Node node) { 238 | return radii[node]; 239 | } 240 | 241 | Map getRadii() { 242 | return Map.from(radii); 243 | } 244 | 245 | Map getPolarLocations() { 246 | return Map.from(polarLocations); 247 | } 248 | 249 | @override 250 | void init(Graph? graph) { 251 | // Implementation can be added if needed 252 | } 253 | 254 | @override 255 | void setDimensions(double width, double height) { 256 | // Implementation can be added if needed 257 | } 258 | 259 | @override 260 | EdgeRenderer? renderer; 261 | } -------------------------------------------------------------------------------- /lib/tree/TreeEdgeRenderer.dart: -------------------------------------------------------------------------------- 1 | part of graphview; 2 | 3 | class TreeEdgeRenderer extends EdgeRenderer { 4 | BuchheimWalkerConfiguration configuration; 5 | 6 | TreeEdgeRenderer(this.configuration); 7 | 8 | var linePath = Path(); 9 | 10 | void render(Canvas canvas, Graph graph, Paint paint) { 11 | graph.edges.forEach((edge) { 12 | renderEdge(canvas, edge, paint); 13 | }); 14 | } 15 | 16 | @override 17 | void renderEdge(Canvas canvas, Edge edge, Paint paint) { 18 | final edgePaint = (edge.paint ?? paint)..style = PaintingStyle.stroke; 19 | var node = edge.source; 20 | var child = edge.destination; 21 | 22 | if (node == child) { 23 | final loopPath = buildSelfLoopPath(edge, arrowLength: 0.0); 24 | if (loopPath != null) { 25 | drawStyledPath(canvas, loopPath.path, edgePaint, lineType: child.lineType); 26 | } 27 | return; 28 | } 29 | 30 | final parentPos = getNodePosition(node); 31 | final childPos = getNodePosition(child); 32 | 33 | final orientation = getEffectiveOrientation(node, child); 34 | 35 | linePath.reset(); 36 | buildEdgePath(node, child, parentPos, childPos, orientation); 37 | 38 | // Check if the destination node has a specific line type 39 | final lineType = child.lineType; 40 | 41 | if (lineType != LineType.Default) { 42 | // For styled lines, we need to draw path segments with the appropriate style 43 | _drawStyledPath(canvas, linePath, edgePaint, lineType); 44 | } else { 45 | canvas.drawPath(linePath, edgePaint); 46 | } 47 | } 48 | 49 | /// Draws a path with the specified line type by converting it to line segments 50 | void _drawStyledPath(Canvas canvas, Path path, Paint paint, LineType lineType) { 51 | // Extract path points for styled rendering 52 | final points = _extractPathPoints(path); 53 | 54 | // Draw each segment with the appropriate style 55 | for (var i = 0; i < points.length - 1; i++) { 56 | drawStyledLine( 57 | canvas, 58 | points[i], 59 | points[i + 1], 60 | paint, 61 | lineType: lineType, 62 | ); 63 | } 64 | } 65 | 66 | /// Extracts key points from a path for segment drawing 67 | List _extractPathPoints(Path path) { 68 | // This is a simplified extraction that works for the L-shaped and curved paths 69 | // For more complex paths, you might need a more sophisticated approach 70 | final points = []; 71 | final metrics = path.computeMetrics(); 72 | 73 | for (var metric in metrics) { 74 | final length = metric.length; 75 | const sampleDistance = 10.0; // Sample every 10 pixels 76 | var distance = 0.0; 77 | 78 | while (distance <= length) { 79 | final tangent = metric.getTangentForOffset(distance); 80 | if (tangent != null) { 81 | points.add(tangent.position); 82 | } 83 | distance += sampleDistance; 84 | } 85 | 86 | // Add the final point 87 | final finalTangent = metric.getTangentForOffset(length); 88 | if (finalTangent != null) { 89 | points.add(finalTangent.position); 90 | } 91 | } 92 | 93 | return points; 94 | } 95 | 96 | int getEffectiveOrientation(Node node, Node child) { 97 | return configuration.orientation; 98 | } 99 | 100 | /// Builds the path for the edge based on orientation 101 | void buildEdgePath(Node node, Node child, Offset parentPos, Offset childPos, int orientation) { 102 | final parentCenterX = parentPos.dx + node.width * 0.5; 103 | final parentCenterY = parentPos.dy + node.height * 0.5; 104 | final childCenterX = childPos.dx + child.width * 0.5; 105 | final childCenterY = childPos.dy + child.height * 0.5; 106 | 107 | if (parentCenterY == childCenterY && parentCenterX == childCenterX) return; 108 | 109 | switch (orientation) { 110 | case BuchheimWalkerConfiguration.ORIENTATION_TOP_BOTTOM: 111 | buildTopBottomPath(node, child, parentPos, childPos, parentCenterX, parentCenterY, childCenterX, childCenterY); 112 | break; 113 | 114 | case BuchheimWalkerConfiguration.ORIENTATION_BOTTOM_TOP: 115 | buildBottomTopPath(node, child, parentPos, childPos, parentCenterX, parentCenterY, childCenterX, childCenterY); 116 | break; 117 | 118 | case BuchheimWalkerConfiguration.ORIENTATION_LEFT_RIGHT: 119 | buildLeftRightPath(node, child, parentPos, childPos, parentCenterX, parentCenterY, childCenterX, childCenterY); 120 | break; 121 | 122 | case BuchheimWalkerConfiguration.ORIENTATION_RIGHT_LEFT: 123 | buildRightLeftPath(node, child, parentPos, childPos, parentCenterX, parentCenterY, childCenterX, childCenterY); 124 | break; 125 | } 126 | } 127 | 128 | /// Builds path for top-bottom orientation 129 | void buildTopBottomPath(Node node, Node child, Offset parentPos, Offset childPos, 130 | double parentCenterX, double parentCenterY, double childCenterX, double childCenterY) { 131 | final parentBottomY = parentPos.dy + node.height * 0.5; 132 | final childTopY = childPos.dy + child.height * 0.5; 133 | final midY = (parentBottomY + childTopY) * 0.5; 134 | 135 | if (configuration.useCurvedConnections) { 136 | // Curved connection 137 | linePath 138 | ..moveTo(childCenterX, childTopY) 139 | ..cubicTo( 140 | childCenterX, midY, 141 | parentCenterX, midY, 142 | parentCenterX, parentBottomY, 143 | ); 144 | } else { 145 | // L-shaped connection 146 | linePath 147 | ..moveTo(parentCenterX, parentBottomY) 148 | ..lineTo(parentCenterX, midY) 149 | ..lineTo(childCenterX, midY) 150 | ..lineTo(childCenterX, childTopY); 151 | } 152 | } 153 | 154 | /// Builds path for bottom-top orientation 155 | void buildBottomTopPath(Node node, Node child, Offset parentPos, Offset childPos, 156 | double parentCenterX, double parentCenterY, double childCenterX, double childCenterY) { 157 | final parentTopY = parentPos.dy + node.height * 0.5; 158 | final childBottomY = childPos.dy + child.height * 0.5; 159 | final midY = (parentTopY + childBottomY) * 0.5; 160 | 161 | if (configuration.useCurvedConnections) { 162 | linePath 163 | ..moveTo(childCenterX, childBottomY) 164 | ..cubicTo( 165 | childCenterX, midY, 166 | parentCenterX, midY, 167 | parentCenterX, parentTopY, 168 | ); 169 | } else { 170 | linePath 171 | ..moveTo(parentCenterX, parentTopY) 172 | ..lineTo(parentCenterX, midY) 173 | ..lineTo(childCenterX, midY) 174 | ..lineTo(childCenterX, childBottomY); 175 | } 176 | } 177 | 178 | /// Builds path for left-right orientation 179 | void buildLeftRightPath(Node node, Node child, Offset parentPos, Offset childPos, 180 | double parentCenterX, double parentCenterY, double childCenterX, double childCenterY) { 181 | final parentRightX = parentPos.dx + node.width * 0.5; 182 | final childLeftX = childPos.dx + child.width * 0.5; 183 | final midX = (parentRightX + childLeftX) * 0.5; 184 | 185 | if (configuration.useCurvedConnections) { 186 | linePath 187 | ..moveTo(childLeftX, childCenterY) 188 | ..cubicTo( 189 | midX, childCenterY, 190 | midX, parentCenterY, 191 | parentRightX, parentCenterY, 192 | ); 193 | } else { 194 | linePath 195 | ..moveTo(parentRightX, parentCenterY) 196 | ..lineTo(midX, parentCenterY) 197 | ..lineTo(midX, childCenterY) 198 | ..lineTo(childLeftX, childCenterY); 199 | } 200 | } 201 | 202 | /// Builds path for right-left orientation 203 | void buildRightLeftPath(Node node, Node child, Offset parentPos, Offset childPos, 204 | double parentCenterX, double parentCenterY, double childCenterX, double childCenterY) { 205 | final parentLeftX = parentPos.dx + node.width * 0.5; 206 | final childRightX = childPos.dx + child.width * 0.5; 207 | final midX = (parentLeftX + childRightX) * 0.5; 208 | 209 | if (configuration.useCurvedConnections) { 210 | linePath 211 | ..moveTo(childRightX, childCenterY) 212 | ..cubicTo( 213 | midX, childCenterY, 214 | midX, parentCenterY, 215 | parentLeftX, parentCenterY, 216 | ); 217 | } else { 218 | linePath 219 | ..moveTo(parentLeftX, parentCenterY) 220 | ..lineTo(midX, parentCenterY) 221 | ..lineTo(midX, childCenterY) 222 | ..lineTo(childRightX, childCenterY); 223 | } 224 | } 225 | } 226 | -------------------------------------------------------------------------------- /lib/Graph.dart: -------------------------------------------------------------------------------- 1 | part of graphview; 2 | 3 | class Graph { 4 | final List _nodes = []; 5 | final List _edges = []; 6 | List graphObserver = []; 7 | 8 | // Cache 9 | final Map> _successorCache = {}; 10 | final Map> _predecessorCache = {}; 11 | bool _cacheValid = false; 12 | 13 | List get nodes => _nodes; 14 | 15 | List get edges => _edges; 16 | 17 | var isTree = false; 18 | 19 | int nodeCount() => _nodes.length; 20 | 21 | void addNode(Node node) { 22 | _nodes.add(node); 23 | _cacheValid = false; 24 | notifyGraphObserver(); 25 | } 26 | 27 | void addNodes(List nodes) => nodes.forEach((it) => addNode(it)); 28 | 29 | void removeNode(Node? node) { 30 | if (!_nodes.contains(node)) return; 31 | 32 | if (isTree) { 33 | successorsOf(node).forEach((element) => removeNode(element)); 34 | } 35 | 36 | _nodes.remove(node); 37 | _edges 38 | .removeWhere((edge) => edge.source == node || edge.destination == node); 39 | _cacheValid = false; 40 | notifyGraphObserver(); 41 | } 42 | 43 | void removeNodes(List nodes) => nodes.forEach((it) => removeNode(it)); 44 | 45 | Edge addEdge(Node source, Node destination, {Paint? paint}) { 46 | final edge = Edge(source, destination, paint: paint); 47 | addEdgeS(edge); 48 | return edge; 49 | } 50 | 51 | void addEdgeS(Edge edge) { 52 | var sourceSet = false; 53 | var destinationSet = false; 54 | for (var node in _nodes) { 55 | if (!sourceSet && node == edge.source) { 56 | edge.source = node; 57 | sourceSet = true; 58 | } 59 | 60 | if (!destinationSet && node == edge.destination) { 61 | edge.destination = node; 62 | destinationSet = true; 63 | } 64 | 65 | if (sourceSet && destinationSet) { 66 | break; 67 | } 68 | } 69 | if (!sourceSet) { 70 | _nodes.add(edge.source); 71 | sourceSet = true; 72 | if (!destinationSet && edge.destination == edge.source) { 73 | destinationSet = true; 74 | } 75 | } 76 | if (!destinationSet) { 77 | _nodes.add(edge.destination); 78 | destinationSet = true; 79 | } 80 | 81 | if (!_edges.contains(edge)) { 82 | _edges.add(edge); 83 | _cacheValid = false; 84 | notifyGraphObserver(); 85 | } 86 | } 87 | 88 | void addEdges(List edges) => edges.forEach((it) => addEdgeS(it)); 89 | 90 | void removeEdge(Edge edge) { 91 | _edges.remove(edge); 92 | _cacheValid = false; 93 | } 94 | 95 | void removeEdges(List edges) => edges.forEach((it) => removeEdge(it)); 96 | 97 | void removeEdgeFromPredecessor(Node? predecessor, Node? current) { 98 | _edges.removeWhere( 99 | (edge) => edge.source == predecessor && edge.destination == current); 100 | _cacheValid = false; 101 | } 102 | 103 | bool hasNodes() => _nodes.isNotEmpty; 104 | 105 | Edge? getEdgeBetween(Node source, Node? destination) => 106 | _edges.firstWhereOrNull((element) => 107 | element.source == source && element.destination == destination); 108 | 109 | bool hasSuccessor(Node? node) => successorsOf(node).isNotEmpty; 110 | 111 | List successorsOf(Node? node) { 112 | if (node == null) return []; 113 | if (!_cacheValid) _buildCache(); 114 | return _successorCache[node] ?? []; 115 | } 116 | 117 | bool hasPredecessor(Node node) => predecessorsOf(node).isNotEmpty; 118 | 119 | List predecessorsOf(Node? node) { 120 | if (node == null) return []; 121 | if (!_cacheValid) _buildCache(); 122 | return _predecessorCache[node] ?? []; 123 | } 124 | 125 | void _buildCache() { 126 | _successorCache.clear(); 127 | _predecessorCache.clear(); 128 | 129 | for (var node in _nodes) { 130 | _successorCache[node] = []; 131 | _predecessorCache[node] = []; 132 | } 133 | 134 | for (var edge in _edges) { 135 | _successorCache[edge.source]!.add(edge.destination); 136 | _predecessorCache[edge.destination]!.add(edge.source); 137 | } 138 | 139 | _cacheValid = true; 140 | } 141 | 142 | bool contains({Node? node, Edge? edge}) => 143 | node != null && _nodes.contains(node) || 144 | edge != null && _edges.contains(edge); 145 | 146 | bool containsData(data) => _nodes.any((element) => element.data == data); 147 | 148 | Node getNodeAtPosition(int position) { 149 | if (position < 0) { 150 | // throw IllegalArgumentException("position can't be negative") 151 | } 152 | 153 | final size = _nodes.length; 154 | if (position >= size) { 155 | // throw IndexOutOfBoundsException("Position: $position, Size: $size") 156 | } 157 | 158 | return _nodes[position]; 159 | } 160 | 161 | @Deprecated('Please use the builder and id mechanism to build the widgets') 162 | Node getNodeAtUsingData(Widget data) => 163 | _nodes.firstWhere((element) => element.data == data); 164 | 165 | Node getNodeUsingKey(ValueKey key) => 166 | _nodes.firstWhere((element) => element.key == key); 167 | 168 | Node getNodeUsingId(dynamic id) => 169 | _nodes.firstWhere((element) => element.key == ValueKey(id)); 170 | 171 | List getOutEdges(Node node) => 172 | _edges.where((element) => element.source == node).toList(); 173 | 174 | List getInEdges(Node node) => 175 | _edges.where((element) => element.destination == node).toList(); 176 | 177 | void notifyGraphObserver() => graphObserver.forEach((element) { 178 | element.notifyGraphInvalidated(); 179 | }); 180 | 181 | String toJson() { 182 | var jsonString = { 183 | 'nodes': [..._nodes.map((e) => e.hashCode.toString())], 184 | 'edges': [ 185 | ..._edges.map((e) => { 186 | 'from': e.source.hashCode.toString(), 187 | 'to': e.destination.hashCode.toString() 188 | }) 189 | ] 190 | }; 191 | 192 | return json.encode(jsonString); 193 | } 194 | 195 | } 196 | 197 | extension GraphExtension on Graph { 198 | Rect calculateGraphBounds() { 199 | var minX = double.infinity; 200 | var minY = double.infinity; 201 | var maxX = double.negativeInfinity; 202 | var maxY = double.negativeInfinity; 203 | 204 | for (final node in nodes) { 205 | minX = min(minX, node.x); 206 | minY = min(minY, node.y); 207 | maxX = max(maxX, node.x + node.width); 208 | maxY = max(maxY, node.y + node.height); 209 | } 210 | 211 | return Rect.fromLTRB(minX, minY, maxX, maxY); 212 | } 213 | 214 | Size calculateGraphSize() { 215 | final bounds = calculateGraphBounds(); 216 | return bounds.size; 217 | } 218 | } 219 | 220 | enum LineType { 221 | Default, 222 | DottedLine, 223 | DashedLine, 224 | SineLine, 225 | } 226 | 227 | class Node { 228 | ValueKey? key; 229 | 230 | @Deprecated('Please use the builder and id mechanism to build the widgets') 231 | Widget? data; 232 | 233 | @Deprecated('Please use the Node.Id') 234 | Node(this.data, {Key? key}) { 235 | this.key = ValueKey(key?.hashCode ?? data.hashCode); 236 | } 237 | 238 | Node.Id(dynamic id) { 239 | key = ValueKey(id); 240 | } 241 | 242 | Size size = Size(0, 0); 243 | 244 | Offset position = Offset(0, 0); 245 | 246 | LineType lineType = LineType.Default; 247 | 248 | double get height => size.height; 249 | 250 | double get width => size.width; 251 | 252 | double get x => position.dx; 253 | 254 | double get y => position.dy; 255 | 256 | set y(double value) { 257 | position = Offset(position.dx, value); 258 | } 259 | 260 | set x(double value) { 261 | position = Offset(value, position.dy); 262 | } 263 | 264 | @override 265 | bool operator ==(Object other) => 266 | identical(this, other) || other is Node && hashCode == other.hashCode; 267 | 268 | @override 269 | int get hashCode { 270 | return key?.value.hashCode ?? key.hashCode; 271 | } 272 | 273 | @override 274 | String toString() { 275 | return 'Node{position: $position, key: $key, _size: $size, lineType: $lineType}'; 276 | } 277 | } 278 | 279 | class Edge { 280 | Node source; 281 | Node destination; 282 | 283 | Key? key; 284 | Paint? paint; 285 | 286 | Edge(this.source, this.destination, {this.key, this.paint}); 287 | 288 | @override 289 | bool operator ==(Object? other) => 290 | identical(this, other) || other is Edge && hashCode == other.hashCode; 291 | 292 | @override 293 | int get hashCode => key?.hashCode ?? Object.hash(source, destination); 294 | } 295 | 296 | abstract class GraphObserver { 297 | void notifyGraphInvalidated(); 298 | } 299 | -------------------------------------------------------------------------------- /example/lib/layer_eiglesperger_graphview.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:graphview/GraphView.dart'; 4 | 5 | class LayeredEiglspergerGraphViewPage extends StatefulWidget { 6 | @override 7 | _LayeredEiglspergerGraphViewPageState createState() => _LayeredEiglspergerGraphViewPageState(); 8 | } 9 | 10 | class _LayeredEiglspergerGraphViewPageState extends State { 11 | GraphViewController _controller = GraphViewController(); 12 | final Random r = Random(); 13 | int nextNodeId = 0; 14 | 15 | @override 16 | Widget build(BuildContext context) { 17 | return Scaffold( 18 | appBar: AppBar(), 19 | body: Column( 20 | mainAxisSize: MainAxisSize.max, 21 | children: [ 22 | Wrap( 23 | children: [ 24 | Container( 25 | width: 100, 26 | child: TextFormField( 27 | initialValue: builder.nodeSeparation.toString(), 28 | decoration: InputDecoration(labelText: 'Node Separation'), 29 | onChanged: (text) { 30 | builder.nodeSeparation = int.tryParse(text) ?? 100; 31 | this.setState(() {}); 32 | }, 33 | ), 34 | ), 35 | Container( 36 | width: 100, 37 | child: TextFormField( 38 | initialValue: builder.levelSeparation.toString(), 39 | decoration: InputDecoration(labelText: 'Level Separation'), 40 | onChanged: (text) { 41 | builder.levelSeparation = int.tryParse(text) ?? 100; 42 | this.setState(() {}); 43 | }, 44 | ), 45 | ), 46 | Container( 47 | width: 100, 48 | child: TextFormField( 49 | initialValue: builder.orientation.toString(), 50 | decoration: InputDecoration(labelText: 'Orientation'), 51 | onChanged: (text) { 52 | builder.orientation = int.tryParse(text) ?? 100; 53 | this.setState(() {}); 54 | }, 55 | ), 56 | ), 57 | Container( 58 | width: 120, 59 | child: Column( 60 | children: [ 61 | Text('Alignment'), 62 | DropdownButton( 63 | value: builder.coordinateAssignment, 64 | items: CoordinateAssignment.values.map((coordinateAssignment) { 65 | return DropdownMenuItem( 66 | value: coordinateAssignment, 67 | child: Text(coordinateAssignment.name), 68 | ); 69 | }).toList(), 70 | onChanged: (value) { 71 | setState(() { 72 | builder.coordinateAssignment = value!; 73 | }); 74 | }, 75 | ), 76 | ], 77 | ), 78 | ), 79 | ElevatedButton( 80 | onPressed: () { 81 | final node12 = Node.Id(r.nextInt(100)); 82 | var edge = graph.getNodeAtPosition(r.nextInt(graph.nodeCount())); 83 | print(edge); 84 | graph.addEdge(edge, node12); 85 | setState(() {}); 86 | }, 87 | child: Text('Add'), 88 | ), 89 | ElevatedButton( 90 | onPressed: () => _navigateToRandomNode(), 91 | child: Text('Go to Node $nextNodeId'), 92 | ), 93 | ElevatedButton( 94 | onPressed: () => _controller.resetView(), 95 | child: Text('Reset View'), 96 | ), 97 | ElevatedButton( 98 | onPressed: () => _controller.zoomToFit(), 99 | child: Text('Zoom to fit'), 100 | ), 101 | ], 102 | ), 103 | Expanded( 104 | child: GraphView.builder( 105 | controller: _controller, 106 | graph: graph, 107 | algorithm: EiglspergerAlgorithm(builder), 108 | paint: Paint() 109 | ..color = Colors.green 110 | ..strokeWidth = 1 111 | ..style = PaintingStyle.stroke, 112 | builder: (Node node) { 113 | var a = node.key!.value as int?; 114 | return rectangleWidget(a); 115 | }, 116 | ), 117 | ), 118 | ], 119 | )); 120 | } 121 | 122 | Widget rectangleWidget(int? a) { 123 | return Container( 124 | padding: EdgeInsets.all(16), 125 | decoration: BoxDecoration( 126 | shape: BoxShape.circle, 127 | boxShadow: [ 128 | BoxShadow(color: Colors.blue[100]!, spreadRadius: 1), 129 | ], 130 | ), 131 | child: Text('${a}')); 132 | } 133 | 134 | final Graph graph = Graph(); 135 | SugiyamaConfiguration builder = SugiyamaConfiguration() 136 | ..bendPointShape = CurvedBendPointShape(curveLength: 20); 137 | 138 | void _navigateToRandomNode() { 139 | if (graph.nodes.isEmpty) return; 140 | 141 | final randomNode = graph.nodes.firstWhere( 142 | (node) => node.key != null && node.key!.value == nextNodeId, 143 | orElse: () => graph.nodes.first, 144 | ); 145 | final nodeId = randomNode.key!; 146 | _controller.animateToNode(nodeId); 147 | 148 | setState(() { 149 | nextNodeId = r.nextInt(graph.nodes.length) + 1; 150 | }); 151 | } 152 | 153 | @override 154 | void initState() { 155 | super.initState(); 156 | final node1 = Node.Id(1); 157 | final node2 = Node.Id(2); 158 | final node3 = Node.Id(3); 159 | final node4 = Node.Id(4); 160 | final node5 = Node.Id(5); 161 | final node6 = Node.Id(6); 162 | final node8 = Node.Id(7); 163 | final node7 = Node.Id(8); 164 | final node9 = Node.Id(9); 165 | final node10 = Node.Id(10); 166 | final node11 = Node.Id(11); 167 | final node12 = Node.Id(12); 168 | final node13 = Node.Id(13); 169 | final node14 = Node.Id(14); 170 | final node15 = Node.Id(15); 171 | final node16 = Node.Id(16); 172 | final node17 = Node.Id(17); 173 | final node18 = Node.Id(18); 174 | final node19 = Node.Id(19); 175 | final node20 = Node.Id(20); 176 | final node21 = Node.Id(21); 177 | final node22 = Node.Id(22); 178 | final node23 = Node.Id(23); 179 | 180 | graph.addEdge(node1, node13, paint: Paint()..color = Colors.red); 181 | graph.addEdge(node1, node21); 182 | graph.addEdge(node1, node4); 183 | graph.addEdge(node1, node3); 184 | graph.addEdge(node2, node3); 185 | graph.addEdge(node2, node20); 186 | graph.addEdge(node3, node4); 187 | graph.addEdge(node3, node5); 188 | graph.addEdge(node3, node23); 189 | graph.addEdge(node4, node6); 190 | graph.addEdge(node5, node7); 191 | graph.addEdge(node6, node8); 192 | graph.addEdge(node6, node16); 193 | graph.addEdge(node6, node23); 194 | graph.addEdge(node7, node9); 195 | graph.addEdge(node8, node10); 196 | graph.addEdge(node8, node11); 197 | graph.addEdge(node9, node12); 198 | graph.addEdge(node10, node13); 199 | graph.addEdge(node10, node14); 200 | graph.addEdge(node10, node15); 201 | graph.addEdge(node11, node15); 202 | graph.addEdge(node11, node16); 203 | graph.addEdge(node12, node20); 204 | graph.addEdge(node13, node17); 205 | graph.addEdge(node14, node17); 206 | graph.addEdge(node14, node18); 207 | graph.addEdge(node16, node18); 208 | graph.addEdge(node16, node19); 209 | graph.addEdge(node16, node20); 210 | graph.addEdge(node18, node21); 211 | graph.addEdge(node19, node22); 212 | graph.addEdge(node21, node23); 213 | graph.addEdge(node22, node23); 214 | graph.addEdge(node1, node22); 215 | graph.addEdge(node7, node8); 216 | 217 | builder 218 | ..nodeSeparation = (15) 219 | ..levelSeparation = (15) 220 | ..orientation = SugiyamaConfiguration.ORIENTATION_TOP_BOTTOM; 221 | 222 | // Set initial random node for navigation 223 | nextNodeId = r.nextInt(22); // 0-21 nodes exist 224 | } 225 | } -------------------------------------------------------------------------------- /lib/tree/CircleLayoutAlgorithm.dart: -------------------------------------------------------------------------------- 1 | part of graphview; 2 | 3 | class CircleLayoutConfiguration { 4 | final double radius; 5 | final bool reduceEdgeCrossing; 6 | final int reduceEdgeCrossingMaxEdges; 7 | 8 | CircleLayoutConfiguration({ 9 | this.radius = 0.0, // 0 means auto-calculate 10 | this.reduceEdgeCrossing = true, 11 | this.reduceEdgeCrossingMaxEdges = 200, 12 | }); 13 | } 14 | 15 | class CircleLayoutAlgorithm extends Algorithm { 16 | final CircleLayoutConfiguration config; 17 | double _radius = 0.0; 18 | List nodeOrderedList = []; 19 | 20 | CircleLayoutAlgorithm(this.config, EdgeRenderer? renderer) { 21 | this.renderer = renderer ?? ArrowEdgeRenderer(); 22 | _radius = config.radius; 23 | } 24 | 25 | @override 26 | Size run(Graph? graph, double shiftX, double shiftY) { 27 | if (graph == null || graph.nodes.isEmpty) { 28 | return Size.zero; 29 | } 30 | 31 | // Handle single node case 32 | if (graph.nodes.length == 1) { 33 | final node = graph.nodes.first; 34 | node.position = Offset(shiftX + 100, shiftY + 100); 35 | return Size(200, 200); 36 | } 37 | 38 | _computeNodeOrder(graph); 39 | final size = _layoutNodes(graph); 40 | _shiftCoordinates(graph, shiftX, shiftY); 41 | 42 | return size; 43 | } 44 | 45 | void _computeNodeOrder(Graph graph) { 46 | final shouldReduceCrossing = config.reduceEdgeCrossing && 47 | graph.edges.length < config.reduceEdgeCrossingMaxEdges; 48 | 49 | if (shouldReduceCrossing) { 50 | nodeOrderedList = _reduceEdgeCrossing(graph); 51 | } else { 52 | nodeOrderedList = List.from(graph.nodes); 53 | } 54 | } 55 | 56 | List _reduceEdgeCrossing(Graph graph) { 57 | // Check if graph has multiple components 58 | final components = _findConnectedComponents(graph); 59 | final orderedList = []; 60 | 61 | if (components.length > 1) { 62 | // Handle each component separately 63 | for (final component in components) { 64 | final componentGraph = _createSubgraph(graph, component); 65 | final componentOrder = _optimizeNodeOrder(componentGraph); 66 | orderedList.addAll(componentOrder); 67 | } 68 | } else { 69 | // Single component 70 | orderedList.addAll(_optimizeNodeOrder(graph)); 71 | } 72 | 73 | return orderedList; 74 | } 75 | 76 | List> _findConnectedComponents(Graph graph) { 77 | final visited = {}; 78 | final components = >[]; 79 | 80 | for (final node in graph.nodes) { 81 | if (!visited.contains(node)) { 82 | final component = {}; 83 | _dfsComponent(graph, node, visited, component); 84 | components.add(component); 85 | } 86 | } 87 | 88 | return components; 89 | } 90 | 91 | void _dfsComponent(Graph graph, Node node, Set visited, Set component) { 92 | visited.add(node); 93 | component.add(node); 94 | 95 | for (final edge in graph.edges) { 96 | Node? neighbor; 97 | if (edge.source == node && !visited.contains(edge.destination)) { 98 | neighbor = edge.destination; 99 | } else if (edge.destination == node && !visited.contains(edge.source)) { 100 | neighbor = edge.source; 101 | } 102 | 103 | if (neighbor != null) { 104 | _dfsComponent(graph, neighbor, visited, component); 105 | } 106 | } 107 | } 108 | 109 | Graph _createSubgraph(Graph originalGraph, Set nodes) { 110 | final subgraph = Graph(); 111 | 112 | // Add nodes 113 | for (final node in nodes) { 114 | subgraph.addNode(node); 115 | } 116 | 117 | // Add edges within the component 118 | for (final edge in originalGraph.edges) { 119 | if (nodes.contains(edge.source) && nodes.contains(edge.destination)) { 120 | subgraph.addEdgeS(edge); 121 | } 122 | } 123 | 124 | return subgraph; 125 | } 126 | 127 | List _optimizeNodeOrder(Graph graph) { 128 | if (graph.nodes.length <= 2) { 129 | return List.from(graph.nodes); 130 | } 131 | 132 | // Simple greedy optimization to reduce edge crossings 133 | var bestOrder = List.from(graph.nodes); 134 | var bestCrossings = _countCrossings(graph, bestOrder); 135 | 136 | // Try a few different starting arrangements 137 | final attempts = min(10, graph.nodes.length); 138 | 139 | for (var attempt = 0; attempt < attempts; attempt++) { 140 | var currentOrder = List.from(graph.nodes); 141 | 142 | // Shuffle starting order 143 | if (attempt > 0) { 144 | currentOrder.shuffle(); 145 | } 146 | 147 | // Local optimization: try swapping adjacent nodes 148 | var improved = true; 149 | var iterations = 0; 150 | const maxIterations = 50; 151 | 152 | while (improved && iterations < maxIterations) { 153 | improved = false; 154 | iterations++; 155 | 156 | for (var i = 0; i < currentOrder.length - 1; i++) { 157 | // Try swapping positions i and i+1 158 | final temp = currentOrder[i]; 159 | currentOrder[i] = currentOrder[i + 1]; 160 | currentOrder[i + 1] = temp; 161 | 162 | final crossings = _countCrossings(graph, currentOrder); 163 | 164 | if (crossings < bestCrossings) { 165 | bestOrder = List.from(currentOrder); 166 | bestCrossings = crossings; 167 | improved = true; 168 | } else { 169 | // Swap back if no improvement 170 | currentOrder[i + 1] = currentOrder[i]; 171 | currentOrder[i] = temp; 172 | } 173 | } 174 | } 175 | } 176 | 177 | return bestOrder; 178 | } 179 | 180 | int _countCrossings(Graph graph, List nodeOrder) { 181 | if (nodeOrder.length < 3) return 0; 182 | 183 | final nodePositions = {}; 184 | for (var i = 0; i < nodeOrder.length; i++) { 185 | nodePositions[nodeOrder[i]] = i; 186 | } 187 | 188 | var crossings = 0; 189 | final edges = graph.edges; 190 | 191 | // Count crossings between all pairs of edges 192 | for (var i = 0; i < edges.length; i++) { 193 | final edge1 = edges[i]; 194 | final pos1a = nodePositions[edge1.source]!; 195 | final pos1b = nodePositions[edge1.destination]!; 196 | 197 | for (var j = i + 1; j < edges.length; j++) { 198 | final edge2 = edges[j]; 199 | final pos2a = nodePositions[edge2.source]!; 200 | final pos2b = nodePositions[edge2.destination]!; 201 | 202 | // Check if edges cross when nodes are arranged in a circle 203 | if (_edgesCross(pos1a, pos1b, pos2a, pos2b, nodeOrder.length)) { 204 | crossings++; 205 | } 206 | } 207 | } 208 | 209 | return crossings; 210 | } 211 | 212 | bool _edgesCross(int pos1a, int pos1b, int pos2a, int pos2b, int totalNodes) { 213 | // Normalize positions so smaller is first 214 | if (pos1a > pos1b) { 215 | final temp = pos1a; 216 | pos1a = pos1b; 217 | pos1b = temp; 218 | } 219 | if (pos2a > pos2b) { 220 | final temp = pos2a; 221 | pos2a = pos2b; 222 | pos2b = temp; 223 | } 224 | 225 | // Check if one edge's endpoints separate the other edge's endpoints on the circle 226 | return (pos1a < pos2a && pos2a < pos1b && pos1b < pos2b) || 227 | (pos2a < pos1a && pos1a < pos2b && pos2b < pos1b); 228 | } 229 | 230 | Size _layoutNodes(Graph graph) { 231 | // Calculate bounds for auto-sizing 232 | var width = 400.0; 233 | var height = 400.0; 234 | 235 | if (_radius <= 0) { 236 | _radius = 0.35 * max(width, height); 237 | } 238 | 239 | final centerX = width / 2; 240 | final centerY = height / 2; 241 | 242 | // Position nodes in circle 243 | for (var i = 0; i < nodeOrderedList.length; i++) { 244 | final node = nodeOrderedList[i]; 245 | final angle = (2 * pi * i) / nodeOrderedList.length; 246 | 247 | final posX = cos(angle) * _radius + centerX; 248 | final posY = sin(angle) * _radius + centerY; 249 | 250 | node.position = Offset(posX, posY); 251 | } 252 | 253 | // Calculate actual bounds based on positioned nodes 254 | final bounds = graph.calculateGraphBounds(); 255 | return Size(bounds.width + 40, bounds.height + 40); // Add some padding 256 | } 257 | 258 | 259 | void _shiftCoordinates(Graph graph, double shiftX, double shiftY) { 260 | for (final node in graph.nodes) { 261 | node.position = Offset(node.x + shiftX, node.y + shiftY); 262 | } 263 | } 264 | 265 | @override 266 | void init(Graph? graph) { 267 | // Implementation can be added if needed 268 | } 269 | 270 | @override 271 | void setDimensions(double width, double height) { 272 | // Implementation can be added if needed 273 | } 274 | 275 | 276 | @override 277 | EdgeRenderer? renderer; 278 | } -------------------------------------------------------------------------------- /lib/edgerenderer/ArrowEdgeRenderer.dart: -------------------------------------------------------------------------------- 1 | part of graphview; 2 | 3 | const double ARROW_DEGREES = 0.5; 4 | const double ARROW_LENGTH = 10; 5 | 6 | class ArrowEdgeRenderer extends EdgeRenderer { 7 | var trianglePath = Path(); 8 | final bool noArrow; 9 | 10 | ArrowEdgeRenderer({this.noArrow = false}); 11 | 12 | Offset _getNodeCenter(Node node) { 13 | final nodePosition = getNodePosition(node); 14 | return Offset( 15 | nodePosition.dx + node.width * 0.5, 16 | nodePosition.dy + node.height * 0.5, 17 | ); 18 | } 19 | 20 | void render(Canvas canvas, Graph graph, Paint paint) { 21 | graph.edges.forEach((edge) { 22 | renderEdge(canvas, edge, paint); 23 | }); 24 | } 25 | 26 | @override 27 | void renderEdge(Canvas canvas, Edge edge, Paint paint) { 28 | var source = edge.source; 29 | var destination = edge.destination; 30 | 31 | final currentPaint = (edge.paint ?? paint)..style = PaintingStyle.stroke; 32 | final lineType = _getLineType(destination); 33 | 34 | if (source == destination) { 35 | final loopResult = buildSelfLoopPath( 36 | edge, 37 | arrowLength: noArrow ? 0.0 : ARROW_LENGTH, 38 | ); 39 | 40 | if (loopResult != null) { 41 | drawStyledPath(canvas, loopResult.path, currentPaint, lineType: lineType); 42 | 43 | if (!noArrow) { 44 | final trianglePaint = Paint() 45 | ..color = edge.paint?.color ?? paint.color 46 | ..style = PaintingStyle.fill; 47 | final triangleCentroid = drawTriangle( 48 | canvas, 49 | trianglePaint, 50 | loopResult.arrowBase.dx, 51 | loopResult.arrowBase.dy, 52 | loopResult.arrowTip.dx, 53 | loopResult.arrowTip.dy, 54 | ); 55 | 56 | drawStyledLine( 57 | canvas, 58 | loopResult.arrowBase, 59 | triangleCentroid, 60 | currentPaint, 61 | lineType: lineType, 62 | ); 63 | } 64 | 65 | return; 66 | } 67 | } 68 | 69 | var sourceOffset = getNodePosition(source); 70 | var destinationOffset = getNodePosition(destination); 71 | 72 | var startX = sourceOffset.dx + source.width * 0.5; 73 | var startY = sourceOffset.dy + source.height * 0.5; 74 | var stopX = destinationOffset.dx + destination.width * 0.5; 75 | var stopY = destinationOffset.dy + destination.height * 0.5; 76 | 77 | var clippedLine = clipLineEnd( 78 | startX, 79 | startY, 80 | stopX, 81 | stopY, 82 | destinationOffset.dx, 83 | destinationOffset.dy, 84 | destination.width, 85 | destination.height); 86 | 87 | if (noArrow) { 88 | // Draw line without arrow, respecting line type 89 | drawStyledLine( 90 | canvas, 91 | Offset(clippedLine[0], clippedLine[1]), 92 | Offset(clippedLine[2], clippedLine[3]), 93 | currentPaint, 94 | lineType: lineType, 95 | ); 96 | } else { 97 | var trianglePaint = Paint() 98 | ..color = paint.color 99 | ..style = PaintingStyle.fill; 100 | 101 | // Draw line with arrow 102 | Paint? edgeTrianglePaint; 103 | if (edge.paint != null) { 104 | edgeTrianglePaint = Paint() 105 | ..color = edge.paint?.color ?? paint.color 106 | ..style = PaintingStyle.fill; 107 | } 108 | 109 | var triangleCentroid = drawTriangle( 110 | canvas, 111 | edgeTrianglePaint ?? trianglePaint, 112 | clippedLine[0], 113 | clippedLine[1], 114 | clippedLine[2], 115 | clippedLine[3]); 116 | 117 | // Draw the line with the appropriate style 118 | drawStyledLine( 119 | canvas, 120 | Offset(clippedLine[0], clippedLine[1]), 121 | triangleCentroid, 122 | currentPaint, 123 | lineType: lineType, 124 | ); 125 | } 126 | } 127 | 128 | /// Helper to get line type from node data if available 129 | LineType? _getLineType(Node node) { 130 | // This assumes you have a way to access node data 131 | // You may need to adjust this based on your actual implementation 132 | if (node is SugiyamaNodeData) { 133 | return node.lineType; 134 | } 135 | return null; 136 | } 137 | 138 | Offset drawTriangle(Canvas canvas, Paint paint, double lineStartX, 139 | double lineStartY, double arrowTipX, double arrowTipY) { 140 | // Calculate direction from line start to arrow tip, then flip 180° to point backwards from tip 141 | var lineDirection = 142 | (atan2(arrowTipY - lineStartY, arrowTipX - lineStartX) + pi); 143 | 144 | // Calculate the two base points of the arrowhead triangle 145 | var leftWingX = 146 | (arrowTipX + ARROW_LENGTH * cos((lineDirection - ARROW_DEGREES))); 147 | var leftWingY = 148 | (arrowTipY + ARROW_LENGTH * sin((lineDirection - ARROW_DEGREES))); 149 | var rightWingX = 150 | (arrowTipX + ARROW_LENGTH * cos((lineDirection + ARROW_DEGREES))); 151 | var rightWingY = 152 | (arrowTipY + ARROW_LENGTH * sin((lineDirection + ARROW_DEGREES))); 153 | 154 | // Draw the triangle: tip -> left wing -> right wing -> back to tip 155 | trianglePath.moveTo(arrowTipX, arrowTipY); // Arrow tip 156 | trianglePath.lineTo(leftWingX, leftWingY); // Left wing 157 | trianglePath.lineTo(rightWingX, rightWingY); // Right wing 158 | trianglePath.close(); // Back to tip 159 | canvas.drawPath(trianglePath, paint); 160 | 161 | // Calculate center point of the triangle 162 | var triangleCenterX = (arrowTipX + leftWingX + rightWingX) / 3; 163 | var triangleCenterY = (arrowTipY + leftWingY + rightWingY) / 3; 164 | 165 | trianglePath.reset(); 166 | return Offset(triangleCenterX, triangleCenterY); 167 | } 168 | 169 | List clipLineEnd( 170 | double startX, 171 | double startY, 172 | double stopX, 173 | double stopY, 174 | double destX, 175 | double destY, 176 | double destWidth, 177 | double destHeight) { 178 | var clippedStopX = stopX; 179 | var clippedStopY = stopY; 180 | 181 | if (startX == stopX && startY == stopY) { 182 | return [startX, startY, clippedStopX, clippedStopY]; 183 | } 184 | 185 | var slope = (startY - stopY) / (startX - stopX); 186 | final halfHeight = destHeight * 0.5; 187 | final halfWidth = destWidth * 0.5; 188 | 189 | // Check vertical edge intersections 190 | if (startX != stopX) { 191 | final halfSlopeWidth = slope * halfWidth; 192 | if (halfSlopeWidth.abs() <= halfHeight) { 193 | if (destX > startX) { 194 | // Left edge intersection 195 | return [startX, startY, stopX - halfWidth, stopY - halfSlopeWidth]; 196 | } else if (destX < startX) { 197 | // Right edge intersection 198 | return [startX, startY, stopX + halfWidth, stopY + halfSlopeWidth]; 199 | } 200 | } 201 | } 202 | 203 | // Check horizontal edge intersections 204 | if (startY != stopY && slope != 0) { 205 | final halfSlopeHeight = halfHeight / slope; 206 | if (halfSlopeHeight.abs() <= halfWidth) { 207 | if (destY < startY) { 208 | // Bottom edge intersection 209 | clippedStopX = stopX + halfSlopeHeight; 210 | clippedStopY = stopY + halfHeight; 211 | } else if (destY > startY) { 212 | // Top edge intersection 213 | clippedStopX = stopX - halfSlopeHeight; 214 | clippedStopY = stopY - halfHeight; 215 | } 216 | } 217 | } 218 | 219 | return [startX, startY, clippedStopX, clippedStopY]; 220 | } 221 | 222 | List clipLine(double startX, double startY, double stopX, 223 | double stopY, Node destination) { 224 | final resultLine = [startX, startY, stopX, stopY]; 225 | 226 | if (startX == stopX && startY == stopY) return resultLine; 227 | 228 | var slope = (startY - stopY) / (startX - stopX); 229 | final halfHeight = destination.height * 0.5; 230 | final halfWidth = destination.width * 0.5; 231 | 232 | // Check vertical edge intersections 233 | if (startX != stopX) { 234 | final halfSlopeWidth = slope * halfWidth; 235 | if (halfSlopeWidth.abs() <= halfHeight) { 236 | if (destination.x > startX) { 237 | // Left edge intersection 238 | resultLine[2] = stopX - halfWidth; 239 | resultLine[3] = stopY - halfSlopeWidth; 240 | return resultLine; 241 | } else if (destination.x < startX) { 242 | // Right edge intersection 243 | resultLine[2] = stopX + halfWidth; 244 | resultLine[3] = stopY + halfSlopeWidth; 245 | return resultLine; 246 | } 247 | } 248 | } 249 | 250 | // Check horizontal edge intersections 251 | if (startY != stopY && slope != 0) { 252 | final halfSlopeHeight = halfHeight / slope; 253 | if (halfSlopeHeight.abs() <= halfWidth) { 254 | if (destination.y < startY) { 255 | // Bottom edge intersection 256 | resultLine[2] = stopX + halfSlopeHeight; 257 | resultLine[3] = stopY + halfHeight; 258 | } else if (destination.y > startY) { 259 | // Top edge intersection 260 | resultLine[2] = stopX - halfSlopeHeight; 261 | resultLine[3] = stopY - halfHeight; 262 | } 263 | } 264 | } 265 | 266 | return resultLine; 267 | } 268 | } 269 | -------------------------------------------------------------------------------- /lib/tree/RadialTreeLayoutAlgorithm.dart: -------------------------------------------------------------------------------- 1 | part of graphview; 2 | 3 | class TreeLayoutNodeData { 4 | Rectangle? bounds; 5 | int depth = 0; 6 | bool visited = false; 7 | List successorNodes = []; 8 | Node? parent; 9 | 10 | TreeLayoutNodeData(); 11 | } 12 | 13 | class RadialTreeLayoutAlgorithm extends Algorithm { 14 | late BuchheimWalkerConfiguration config; 15 | final Map nodeData = {}; 16 | final Map baseBounds = {}; 17 | final Map polarLocations = {}; 18 | 19 | RadialTreeLayoutAlgorithm(this.config, EdgeRenderer? renderer) { 20 | this.renderer = renderer ?? ArrowEdgeRenderer(); 21 | } 22 | 23 | @override 24 | Size run(Graph? graph, double shiftX, double shiftY) { 25 | if (graph == null || graph.nodes.isEmpty) { 26 | return Size.zero; 27 | } 28 | 29 | nodeData.clear(); 30 | baseBounds.clear(); 31 | polarLocations.clear(); 32 | 33 | // Handle single node case 34 | if (graph.nodes.length == 1) { 35 | final node = graph.nodes.first; 36 | node.position = Offset(shiftX + 100, shiftY + 100); 37 | return Size(200, 200); 38 | } 39 | 40 | _initializeData(graph); 41 | final roots = _findRoots(graph); 42 | 43 | if (roots.isEmpty) { 44 | final spanningTree = _createSpanningTree(graph); 45 | return _layoutSpanningTree(spanningTree, shiftX, shiftY); 46 | } 47 | 48 | // First, build the tree using regular tree layout 49 | _buildRegularTree(graph, roots); 50 | 51 | // Then convert to radial coordinates 52 | _setRadialLocations(graph); 53 | 54 | // Convert polar to cartesian and position nodes 55 | _putRadialPointsInModel(graph); 56 | 57 | _shiftCoordinates(graph, shiftX, shiftY); 58 | 59 | return graph.calculateGraphSize(); 60 | } 61 | 62 | void _initializeData(Graph graph) { 63 | // Initialize node data 64 | for (final node in graph.nodes) { 65 | nodeData[node] = TreeLayoutNodeData(); 66 | } 67 | 68 | // Build tree structure from edges 69 | for (final edge in graph.edges) { 70 | final source = edge.source; 71 | final target = edge.destination; 72 | 73 | nodeData[source]!.successorNodes.add(target); 74 | nodeData[target]!.parent = source; 75 | } 76 | } 77 | 78 | List _findRoots(Graph graph) { 79 | return graph.nodes.where((node) { 80 | return nodeData[node]!.parent == null && successorsOf(node).isNotEmpty; 81 | }).toList(); 82 | } 83 | 84 | void _buildRegularTree(Graph graph, List roots) { 85 | _calculateSubtreeDimensions(roots); 86 | _positionNodes(roots); 87 | } 88 | 89 | void _calculateSubtreeDimensions(List roots) { 90 | final visited = {}; 91 | 92 | for (final root in roots) { 93 | _calculateWidth(root, visited); 94 | } 95 | 96 | visited.clear(); 97 | for (final root in roots) { 98 | _calculateHeight(root, visited); 99 | } 100 | } 101 | 102 | int _calculateWidth(Node node, Set visited) { 103 | if (!visited.add(node)) return 0; 104 | 105 | final children = successorsOf(node); 106 | if (children.isEmpty) { 107 | final width = max(node.width.toInt(), config.siblingSeparation); 108 | baseBounds[node] = Size(width.toDouble(), 0); 109 | return width; 110 | } 111 | 112 | var totalWidth = 0; 113 | for (var i = 0; i < children.length; i++) { 114 | totalWidth += _calculateWidth(children[i], visited); 115 | if (i < children.length - 1) { 116 | totalWidth += config.siblingSeparation; 117 | } 118 | } 119 | 120 | baseBounds[node] = Size(totalWidth.toDouble(), 0); 121 | return totalWidth; 122 | } 123 | 124 | int _calculateHeight(Node node, Set visited) { 125 | if (!visited.add(node)) return 0; 126 | 127 | final children = successorsOf(node); 128 | if (children.isEmpty) { 129 | final height = max(node.height.toInt(), config.levelSeparation); 130 | final current = baseBounds[node]!; 131 | baseBounds[node] = Size(current.width, height.toDouble()); 132 | return height; 133 | } 134 | 135 | var maxChildHeight = 0; 136 | for (final child in children) { 137 | maxChildHeight = max(maxChildHeight, _calculateHeight(child, visited)); 138 | } 139 | 140 | final totalHeight = maxChildHeight + config.levelSeparation; 141 | final current = baseBounds[node]!; 142 | baseBounds[node] = Size(current.width, totalHeight.toDouble()); 143 | return totalHeight; 144 | } 145 | 146 | void _positionNodes(List roots) { 147 | var currentX = config.siblingSeparation.toDouble(); 148 | 149 | for (final root in roots) { 150 | final rootWidth = baseBounds[root]!.width; 151 | currentX += rootWidth / 2; 152 | 153 | _buildTree(root, currentX, config.levelSeparation.toDouble(), {}); 154 | 155 | currentX += rootWidth / 2 + config.siblingSeparation; 156 | } 157 | } 158 | 159 | void _buildTree(Node node, double x, double y, Set visited) { 160 | if (!visited.add(node)) return; 161 | 162 | node.position = Offset(x, y); 163 | 164 | final children = successorsOf(node); 165 | if (children.isEmpty) return; 166 | 167 | final nextY = y + config.levelSeparation; 168 | final totalWidth = baseBounds[node]!.width; 169 | var childX = x - totalWidth / 2; 170 | 171 | for (final child in children) { 172 | final childWidth = baseBounds[child]!.width; 173 | childX += childWidth / 2; 174 | 175 | _buildTree(child, childX, nextY, visited); 176 | 177 | childX += childWidth / 2 + config.siblingSeparation; 178 | } 179 | } 180 | 181 | void _setRadialLocations(Graph graph) { 182 | final bounds = graph.calculateGraphBounds(); 183 | final maxPoint = bounds.width; 184 | 185 | // Calculate theta step based on maximum x coordinate 186 | final theta = 2 * pi / maxPoint; 187 | final deltaRadius = 1.0; 188 | final offset = _findRoots(graph).length > 1 ? config.levelSeparation.toDouble() : 0.0; 189 | 190 | for (final node in graph.nodes) { 191 | final position = node.position; 192 | 193 | // Convert cartesian tree coordinates to polar coordinates 194 | final polarTheta = position.dx * theta; 195 | final polarRadius = (offset + position.dy - config.levelSeparation) * deltaRadius; 196 | 197 | final polarPoint = PolarPoint.of(polarTheta, polarRadius); 198 | polarLocations[node] = polarPoint; 199 | } 200 | } 201 | 202 | void _putRadialPointsInModel(Graph graph) { 203 | final diameter = _calculateDiameter(); 204 | final center = diameter * 0.5 * 0.5; 205 | 206 | polarLocations.forEach((node, polarPoint) { 207 | final cartesian = polarPoint.toCartesian(); 208 | node.position = Offset(center + cartesian.dx, center + cartesian.dy); 209 | }); 210 | } 211 | 212 | double _calculateDiameter() { 213 | if (polarLocations.isEmpty) return 400.0; 214 | 215 | double maxRadius = 0; 216 | polarLocations.values.forEach((polarPoint) { 217 | maxRadius = max(maxRadius, polarPoint.radius * 2); 218 | }); 219 | 220 | return maxRadius + config.siblingSeparation; 221 | } 222 | 223 | void _shiftCoordinates(Graph graph, double shiftX, double shiftY) { 224 | for (final node in graph.nodes) { 225 | node.position = Offset(node.x + shiftX, node.y + shiftY); 226 | } 227 | } 228 | 229 | Graph _createSpanningTree(Graph graph) { 230 | final visited = {}; 231 | final spanningEdges = []; 232 | 233 | if (graph.nodes.isNotEmpty) { 234 | final startNode = graph.nodes.first; 235 | final queue = [startNode]; 236 | visited.add(startNode); 237 | 238 | while (queue.isNotEmpty) { 239 | final current = queue.removeAt(0); 240 | 241 | for (final edge in graph.edges) { 242 | Node? neighbor; 243 | if (edge.source == current && !visited.contains(edge.destination)) { 244 | neighbor = edge.destination; 245 | spanningEdges.add(edge); 246 | } else if (edge.destination == current && !visited.contains(edge.source)) { 247 | neighbor = edge.source; 248 | spanningEdges.add(Edge(current, edge.source)); 249 | } 250 | 251 | if (neighbor != null && !visited.contains(neighbor)) { 252 | visited.add(neighbor); 253 | queue.add(neighbor); 254 | } 255 | } 256 | } 257 | } 258 | 259 | return Graph()..addEdges(spanningEdges); 260 | } 261 | 262 | Size _layoutSpanningTree(Graph spanningTree, double shiftX, double shiftY) { 263 | nodeData.clear(); 264 | baseBounds.clear(); 265 | polarLocations.clear(); 266 | 267 | _initializeData(spanningTree); 268 | final roots = _findRoots(spanningTree); 269 | 270 | if (roots.isEmpty && spanningTree.nodes.isNotEmpty) { 271 | final fakeRoot = spanningTree.nodes.first; 272 | _buildRegularTree(spanningTree, [fakeRoot]); 273 | } else { 274 | _buildRegularTree(spanningTree, roots); 275 | } 276 | 277 | _setRadialLocations(spanningTree); 278 | _putRadialPointsInModel(spanningTree); 279 | 280 | _shiftCoordinates(spanningTree, shiftX, shiftY); 281 | return spanningTree.calculateGraphSize(); 282 | } 283 | 284 | @override 285 | void init(Graph? graph) { 286 | // Implementation can be added if needed 287 | } 288 | 289 | @override 290 | void setDimensions(double width, double height) { 291 | // Implementation can be added if needed 292 | } 293 | 294 | List successorsOf(Node? node) { 295 | return nodeData[node]!.successorNodes; 296 | } 297 | 298 | 299 | 300 | @override 301 | EdgeRenderer? renderer; 302 | } -------------------------------------------------------------------------------- /example/lib/mindmap_graphview.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:graphview/GraphView.dart'; 5 | 6 | class MindMapPage extends StatefulWidget { 7 | @override 8 | _MindMapPageState createState() => _MindMapPageState(); 9 | } 10 | 11 | class _MindMapPageState extends State with TickerProviderStateMixin { 12 | 13 | GraphViewController _controller = GraphViewController(); 14 | final Random r = Random(); 15 | int nextNodeId = 1; 16 | 17 | @override 18 | Widget build(BuildContext context) { 19 | return Scaffold( 20 | appBar: AppBar( 21 | title: Text('Tree View'), 22 | ), 23 | body: Column( 24 | mainAxisSize: MainAxisSize.max, 25 | children: [ 26 | // Configuration controls 27 | Wrap( 28 | children: [ 29 | Container( 30 | width: 100, 31 | child: TextFormField( 32 | initialValue: builder.siblingSeparation.toString(), 33 | decoration: InputDecoration(labelText: 'Sibling Separation'), 34 | onChanged: (text) { 35 | builder.siblingSeparation = int.tryParse(text) ?? 100; 36 | this.setState(() {}); 37 | }, 38 | ), 39 | ), 40 | Container( 41 | width: 100, 42 | child: TextFormField( 43 | initialValue: builder.levelSeparation.toString(), 44 | decoration: InputDecoration(labelText: 'Level Separation'), 45 | onChanged: (text) { 46 | builder.levelSeparation = int.tryParse(text) ?? 100; 47 | this.setState(() {}); 48 | }, 49 | ), 50 | ), 51 | Container( 52 | width: 100, 53 | child: TextFormField( 54 | initialValue: builder.subtreeSeparation.toString(), 55 | decoration: InputDecoration(labelText: 'Subtree separation'), 56 | onChanged: (text) { 57 | builder.subtreeSeparation = int.tryParse(text) ?? 100; 58 | this.setState(() {}); 59 | }, 60 | ), 61 | ), 62 | Container( 63 | width: 100, 64 | child: TextFormField( 65 | initialValue: builder.orientation.toString(), 66 | decoration: InputDecoration(labelText: 'Orientation'), 67 | onChanged: (text) { 68 | builder.orientation = int.tryParse(text) ?? 100; 69 | this.setState(() {}); 70 | }, 71 | ), 72 | ), 73 | ElevatedButton( 74 | onPressed: () { 75 | final node12 = Node.Id(r.nextInt(100)); 76 | var edge = graph.getNodeAtPosition(r.nextInt(graph.nodeCount())); 77 | print(edge); 78 | graph.addEdge(edge, node12); 79 | setState(() {}); 80 | }, 81 | child: Text('Add'), 82 | ), 83 | ElevatedButton( 84 | onPressed: _navigateToRandomNode, 85 | child: Text('Go to Node $nextNodeId'), 86 | ), 87 | SizedBox(width: 8), 88 | ElevatedButton( 89 | onPressed: _resetView, 90 | child: Text('Reset View'), 91 | ), 92 | SizedBox(width: 8,), 93 | ElevatedButton(onPressed: (){ 94 | _controller.zoomToFit(); 95 | }, child: Text('Zoom to fit')) 96 | ], 97 | ), 98 | 99 | Expanded( 100 | child: GraphView.builder( 101 | controller: _controller, 102 | graph: graph, 103 | algorithm: MindmapAlgorithm( 104 | builder, MindmapEdgeRenderer(builder) 105 | ), 106 | builder: (Node node) => Container( 107 | padding: EdgeInsets.all(16), 108 | decoration: BoxDecoration( 109 | color: Colors.white, 110 | borderRadius: BorderRadius.circular(4), 111 | boxShadow: [BoxShadow(color: Colors.blue[100]!, spreadRadius: 1)], 112 | ), 113 | child: Text( 114 | 'Node ${node.key?.value}', 115 | ), 116 | ), 117 | ), 118 | ), 119 | ], 120 | )); 121 | } 122 | 123 | Widget rectangleWidget(int? a) { 124 | return InkWell( 125 | onTap: () { 126 | print('clicked node $a'); 127 | }, 128 | child: Container( 129 | padding: EdgeInsets.all(16), 130 | decoration: BoxDecoration( 131 | borderRadius: BorderRadius.circular(4), 132 | boxShadow: [ 133 | BoxShadow(color: Colors.blue[100]!, spreadRadius: 1), 134 | ], 135 | ), 136 | child: Text('Node ${a} ')), 137 | ); 138 | } 139 | 140 | final Graph graph = Graph()..isTree = true; 141 | BuchheimWalkerConfiguration builder = BuchheimWalkerConfiguration(); 142 | 143 | void _navigateToRandomNode() { 144 | if (graph.nodes.isEmpty) return; 145 | 146 | final randomNode = graph.nodes.firstWhere( 147 | (node) => node.key != null && node.key!.value == nextNodeId, 148 | orElse: () => graph.nodes.first, 149 | ); 150 | final nodeId = randomNode.key!; 151 | _controller.animateToNode(nodeId); 152 | 153 | setState(() { 154 | nextNodeId = r.nextInt(graph.nodes.length) + 1; 155 | }); 156 | } 157 | 158 | void _resetView() { 159 | _controller.resetView(); 160 | } 161 | 162 | @override 163 | void initState() { 164 | super.initState(); 165 | 166 | 167 | // Complex Mindmap Test - This will stress test the balancing algorithm 168 | 169 | // Create all nodes 170 | final root = Node.Id(1); // Central topic 171 | 172 | // Left side - Technology branch (will be large) 173 | final tech = Node.Id(2); 174 | final ai = Node.Id(3); 175 | final web = Node.Id(4); 176 | final mobile = Node.Id(5); 177 | final aiSubtopics = [ 178 | Node.Id(6), // Machine Learning 179 | Node.Id(7), // Deep Learning 180 | Node.Id(8), // NLP 181 | Node.Id(9), // Computer Vision 182 | ]; 183 | final webSubtopics = [ 184 | Node.Id(10), // Frontend 185 | Node.Id(11), // Backend 186 | Node.Id(12), // DevOps 187 | ]; 188 | final frontendDetails = [ 189 | Node.Id(13), // React 190 | Node.Id(14), // Vue 191 | Node.Id(15), // Angular 192 | ]; 193 | final backendDetails = [ 194 | Node.Id(16), // Node.js 195 | Node.Id(17), // Python 196 | Node.Id(18), // Java 197 | Node.Id(19), // Go 198 | ]; 199 | 200 | // Right side - Business branch (will be smaller to test balancing) 201 | final business = Node.Id(20); 202 | final marketing = Node.Id(21); 203 | final sales = Node.Id(22); 204 | final finance = Node.Id(23); 205 | final marketingDetails = [ 206 | Node.Id(24), // Digital Marketing 207 | Node.Id(25), // Content Strategy 208 | ]; 209 | final salesDetails = [ 210 | Node.Id(26), // B2B Sales 211 | Node.Id(27), // Customer Success 212 | ]; 213 | 214 | // Additional right side - Personal branch 215 | final personal = Node.Id(28); 216 | final health = Node.Id(29); 217 | final hobbies = Node.Id(30); 218 | final healthDetails = [ 219 | Node.Id(31), // Exercise 220 | Node.Id(32), // Nutrition 221 | Node.Id(33), // Mental Health 222 | ]; 223 | final exerciseDetails = [ 224 | Node.Id(34), // Cardio 225 | Node.Id(35), // Strength Training 226 | Node.Id(36), // Yoga 227 | ]; 228 | 229 | // Build the graph structure 230 | graph.addEdge(root, tech); 231 | graph.addEdge(root, business, paint: Paint()..color = Colors.blue); 232 | graph.addEdge(root, personal, paint: Paint()..color = Colors.green); 233 | 234 | // Technology branch (left side - large subtree) 235 | graph.addEdge(tech, ai); 236 | graph.addEdge(tech, web); 237 | graph.addEdge(tech, mobile); 238 | 239 | // AI subtree 240 | for (final aiNode in aiSubtopics) { 241 | graph.addEdge(ai, aiNode, paint: Paint()..color = Colors.purple); 242 | } 243 | 244 | // Web subtree with deep nesting 245 | for (final webNode in webSubtopics) { 246 | graph.addEdge(web, webNode, paint: Paint()..color = Colors.orange); 247 | } 248 | 249 | // Frontend details (3rd level) 250 | for (final frontendNode in frontendDetails) { 251 | graph.addEdge(webSubtopics[0], frontendNode, paint: Paint()..color = Colors.cyan); 252 | } 253 | 254 | // Backend details (3rd level) - even deeper 255 | for (final backendNode in backendDetails) { 256 | graph.addEdge(webSubtopics[1], backendNode, paint: Paint()..color = Colors.teal); 257 | } 258 | 259 | // Business branch (right side - smaller subtree) 260 | graph.addEdge(business, marketing); 261 | graph.addEdge(business, sales); 262 | graph.addEdge(business, finance); 263 | 264 | // Marketing details 265 | for (final marketingNode in marketingDetails) { 266 | graph.addEdge(marketing, marketingNode, paint: Paint()..color = Colors.red); 267 | } 268 | 269 | // Sales details 270 | for (final salesNode in salesDetails) { 271 | graph.addEdge(sales, salesNode, paint: Paint()..color = Colors.indigo); 272 | } 273 | 274 | // Personal branch (right side - medium subtree) 275 | graph.addEdge(personal, health); 276 | graph.addEdge(personal, hobbies); 277 | 278 | // Health details 279 | for (final healthNode in healthDetails) { 280 | graph.addEdge(health, healthNode, paint: Paint()..color = Colors.lightGreen); 281 | } 282 | 283 | // Exercise details (3rd level) 284 | for (final exerciseNode in exerciseDetails) { 285 | graph.addEdge(healthDetails[0], exerciseNode, paint: Paint()..color = Colors.amber); 286 | } 287 | 288 | builder 289 | ..siblingSeparation = (100) 290 | ..levelSeparation = (150) 291 | ..subtreeSeparation = (150) 292 | ..orientation = (BuchheimWalkerConfiguration.ORIENTATION_TOP_BOTTOM); 293 | } 294 | 295 | } -------------------------------------------------------------------------------- /example/lib/tree_graphview.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math'; 2 | 3 | import 'package:flutter/material.dart'; 4 | import 'package:graphview/GraphView.dart'; 5 | 6 | class TreeViewPage extends StatefulWidget { 7 | @override 8 | _TreeViewPageState createState() => _TreeViewPageState(); 9 | } 10 | 11 | class _TreeViewPageState extends State with TickerProviderStateMixin { 12 | 13 | GraphViewController _controller = GraphViewController(); 14 | final Random r = Random(); 15 | int nextNodeId = 1; 16 | 17 | @override 18 | Widget build(BuildContext context) { 19 | return Scaffold( 20 | appBar: AppBar( 21 | title: Text('Tree View'), 22 | ), 23 | body: Column( 24 | mainAxisSize: MainAxisSize.max, 25 | children: [ 26 | // Configuration controls 27 | Wrap( 28 | children: [ 29 | Container( 30 | width: 100, 31 | child: TextFormField( 32 | initialValue: builder.siblingSeparation.toString(), 33 | decoration: InputDecoration(labelText: 'Sibling Separation'), 34 | onChanged: (text) { 35 | builder.siblingSeparation = int.tryParse(text) ?? 100; 36 | this.setState(() {}); 37 | }, 38 | ), 39 | ), 40 | Container( 41 | width: 100, 42 | child: TextFormField( 43 | initialValue: builder.levelSeparation.toString(), 44 | decoration: InputDecoration(labelText: 'Level Separation'), 45 | onChanged: (text) { 46 | builder.levelSeparation = int.tryParse(text) ?? 100; 47 | this.setState(() {}); 48 | }, 49 | ), 50 | ), 51 | Container( 52 | width: 100, 53 | child: TextFormField( 54 | initialValue: builder.subtreeSeparation.toString(), 55 | decoration: InputDecoration(labelText: 'Subtree separation'), 56 | onChanged: (text) { 57 | builder.subtreeSeparation = int.tryParse(text) ?? 100; 58 | this.setState(() {}); 59 | }, 60 | ), 61 | ), 62 | Container( 63 | width: 100, 64 | child: TextFormField( 65 | initialValue: builder.orientation.toString(), 66 | decoration: InputDecoration(labelText: 'Orientation'), 67 | onChanged: (text) { 68 | builder.orientation = int.tryParse(text) ?? 100; 69 | this.setState(() {}); 70 | }, 71 | ), 72 | ), 73 | ElevatedButton( 74 | onPressed: () { 75 | final node12 = Node.Id(r.nextInt(100)); 76 | var edge = graph.getNodeAtPosition(r.nextInt(graph.nodeCount())); 77 | print(edge); 78 | graph.addEdge(edge, node12); 79 | setState(() {}); 80 | }, 81 | child: Text('Add'), 82 | ), 83 | ElevatedButton( 84 | onPressed: _navigateToRandomNode, 85 | child: Text('Go to Node $nextNodeId'), 86 | ), 87 | SizedBox(width: 8), 88 | ElevatedButton( 89 | onPressed: _resetView, 90 | child: Text('Reset View'), 91 | ), 92 | SizedBox(width: 8,), 93 | ElevatedButton(onPressed: (){ 94 | _controller.zoomToFit(); 95 | }, child: Text('Zoom to fit')) 96 | ], 97 | ), 98 | 99 | Expanded( 100 | child: GraphView.builder( 101 | controller: _controller, 102 | graph: graph, 103 | algorithm: algorithm, 104 | initialNode: ValueKey(1), 105 | panAnimationDuration: Duration(milliseconds: 600), 106 | toggleAnimationDuration: Duration(milliseconds: 600), 107 | centerGraph: true, 108 | builder: (Node node) => GestureDetector( 109 | onTap: () => _toggleCollapse(node), 110 | child: Container( 111 | padding: EdgeInsets.all(16), 112 | decoration: BoxDecoration( 113 | color: Colors.white, 114 | borderRadius: BorderRadius.circular(4), 115 | boxShadow: [BoxShadow(color: Colors.blue[100]!, spreadRadius: 1)], 116 | ), 117 | child: Text( 118 | 'Node ${node.key?.value}', 119 | ), 120 | ), 121 | ), 122 | ), 123 | ), 124 | ], 125 | )); 126 | } 127 | 128 | final Graph graph = Graph()..isTree = true; 129 | BuchheimWalkerConfiguration builder = BuchheimWalkerConfiguration(); 130 | late final algorithm = BuchheimWalkerAlgorithm(builder, TreeEdgeRenderer(builder)); 131 | 132 | void _toggleCollapse(Node node) { 133 | _controller.toggleNodeExpanded(graph, node, animate: true); 134 | } 135 | 136 | void _navigateToRandomNode() { 137 | if (graph.nodes.isEmpty) return; 138 | 139 | final randomNode = graph.nodes.firstWhere( 140 | (node) => node.key != null && node.key!.value == nextNodeId, 141 | orElse: () => graph.nodes.first, 142 | ); 143 | final nodeId = randomNode.key!; 144 | _controller.animateToNode(nodeId); 145 | 146 | setState(() { 147 | // nextNodeId = r.nextInt(graph.nodes.length) + 1; 148 | }); 149 | } 150 | 151 | void _resetView() { 152 | _controller.animateToNode(ValueKey(1)); 153 | } 154 | 155 | @override 156 | void initState() { 157 | super.initState(); 158 | 159 | 160 | 161 | // Create all nodes 162 | final root = Node.Id(1); // Central topic 163 | 164 | // Left side - Technology branch (will be large) 165 | final tech = Node.Id(2); 166 | final ai = Node.Id(3); 167 | final web = Node.Id(4); 168 | final mobile = Node.Id(5); 169 | final aiSubtopics = [ 170 | Node.Id(6), // Machine Learning 171 | Node.Id(7), // Deep Learning 172 | Node.Id(8), // NLP 173 | Node.Id(9), // Computer Vision 174 | ]; 175 | final webSubtopics = [ 176 | Node.Id(10), // Frontend 177 | Node.Id(11), // Backend 178 | Node.Id(12), // DevOps 179 | ]; 180 | final frontendDetails = [ 181 | Node.Id(13), // React 182 | Node.Id(14), // Vue 183 | Node.Id(15), // Angular 184 | ]; 185 | final backendDetails = [ 186 | Node.Id(16), // Node.js 187 | Node.Id(17), // Python 188 | Node.Id(18), // Java 189 | Node.Id(19), // Go 190 | ]; 191 | 192 | // Right side - Business branch (will be smaller to test balancing) 193 | final business = Node.Id(20); 194 | final marketing = Node.Id(21); 195 | final sales = Node.Id(22); 196 | final finance = Node.Id(23); 197 | final marketingDetails = [ 198 | Node.Id(24), // Digital Marketing 199 | Node.Id(25), // Content Strategy 200 | ]; 201 | final salesDetails = [ 202 | Node.Id(26), // B2B Sales 203 | Node.Id(27), // Customer Success 204 | ]; 205 | 206 | // Additional right side - Personal branch 207 | final personal = Node.Id(28); 208 | final health = Node.Id(29); 209 | final hobbies = Node.Id(30); 210 | final healthDetails = [ 211 | Node.Id(31), // Exercise 212 | Node.Id(32), // Nutrition 213 | Node.Id(33), // Mental Health 214 | ]; 215 | final exerciseDetails = [ 216 | Node.Id(34), // Cardio 217 | Node.Id(35), // Strength Training 218 | Node.Id(36), // Yoga 219 | ]; 220 | 221 | // Build the graph structure 222 | graph.addEdge(root, tech); 223 | graph.addEdge(root, business, paint: Paint()..color = Colors.blue); 224 | graph.addEdge(root, personal, paint: Paint()..color = Colors.green); 225 | 226 | // // Technology branch (left side - large subtree) 227 | graph.addEdge(tech, ai); 228 | graph.addEdge(tech, web); 229 | graph.addEdge(tech, mobile); 230 | 231 | // AI subtree 232 | for (final aiNode in aiSubtopics) { 233 | graph.addEdge(ai, aiNode, paint: Paint()..color = Colors.purple); 234 | } 235 | 236 | // Web subtree with deep nesting 237 | for (final webNode in webSubtopics) { 238 | graph.addEdge(web, webNode, paint: Paint()..color = Colors.orange); 239 | } 240 | 241 | // Frontend details (3rd level) 242 | for (final frontendNode in frontendDetails) { 243 | graph.addEdge(webSubtopics[0], frontendNode, paint: Paint()..color = Colors.cyan); 244 | } 245 | 246 | // Backend details (3rd level) - even deeper 247 | for (final backendNode in backendDetails) { 248 | graph.addEdge(webSubtopics[1], backendNode, paint: Paint()..color = Colors.teal); 249 | } 250 | 251 | // Business branch (right side - smaller subtree) 252 | graph.addEdge(business, marketing); 253 | graph.addEdge(business, sales); 254 | graph.addEdge(business, finance); 255 | 256 | // Marketing details 257 | for (final marketingNode in marketingDetails) { 258 | graph.addEdge(marketing, marketingNode, paint: Paint()..color = Colors.red); 259 | } 260 | 261 | // Sales details 262 | for (final salesNode in salesDetails) { 263 | graph.addEdge(sales, salesNode, paint: Paint()..color = Colors.indigo); 264 | } 265 | 266 | // Personal branch (right side - medium subtree) 267 | graph.addEdge(personal, health); 268 | graph.addEdge(personal, hobbies); 269 | 270 | // Health details 271 | for (final healthNode in healthDetails) { 272 | graph.addEdge(health, healthNode, paint: Paint()..color = Colors.lightGreen); 273 | } 274 | 275 | // Exercise details (3rd level) 276 | for (final exerciseNode in exerciseDetails) { 277 | graph.addEdge(healthDetails[0], exerciseNode, paint: Paint()..color = Colors.amber); 278 | } 279 | _controller.setInitiallyCollapsedNodes(graph, [tech, business, personal]); 280 | 281 | builder 282 | ..siblingSeparation = (100) 283 | ..levelSeparation = (150) 284 | ..subtreeSeparation = (150) 285 | ..useCurvedConnections = true 286 | ..orientation = (BuchheimWalkerConfiguration.ORIENTATION_TOP_BOTTOM); 287 | } 288 | 289 | } -------------------------------------------------------------------------------- /example/lib/layer_graphview.dart: -------------------------------------------------------------------------------- 1 | import 'dart:math'; 2 | import 'package:flutter/material.dart'; 3 | import 'package:graphview/GraphView.dart'; 4 | 5 | class LayeredGraphViewPage extends StatefulWidget { 6 | @override 7 | _LayeredGraphViewPageState createState() => _LayeredGraphViewPageState(); 8 | } 9 | 10 | class _LayeredGraphViewPageState extends State { 11 | final GraphViewController _controller = GraphViewController(); 12 | final Random r = Random(); 13 | int nextNodeId = 0; 14 | bool _showControls = true; 15 | 16 | @override 17 | Widget build(BuildContext context) { 18 | return Scaffold( 19 | backgroundColor: Colors.grey[50], 20 | appBar: AppBar( 21 | title: Text('Graph Visualizer', style: TextStyle(fontWeight: FontWeight.w600)), 22 | backgroundColor: Colors.white, 23 | foregroundColor: Colors.grey[800], 24 | elevation: 0, 25 | actions: [ 26 | IconButton( 27 | icon: Icon(_showControls ? Icons.visibility_off : Icons.visibility), 28 | onPressed: () => setState(() => _showControls = !_showControls), 29 | tooltip: 'Toggle Controls', 30 | ), 31 | IconButton( 32 | icon: Icon(Icons.shuffle), 33 | onPressed: _navigateToRandomNode, 34 | tooltip: 'Random Node', 35 | ), 36 | ], 37 | ), 38 | body: Column( 39 | children: [ 40 | AnimatedContainer( 41 | duration: Duration(milliseconds: 300), 42 | height: _showControls ? null : 0, 43 | child: AnimatedOpacity( 44 | duration: Duration(milliseconds: 300), 45 | opacity: _showControls ? 1.0 : 0.0, 46 | child: _buildControlPanel(), 47 | ), 48 | ), 49 | Expanded(child: _buildGraphView()), 50 | ], 51 | ), 52 | ); 53 | } 54 | 55 | Widget _buildControlPanel() { 56 | return Container( 57 | margin: EdgeInsets.all(16), 58 | padding: EdgeInsets.all(20), 59 | decoration: BoxDecoration( 60 | color: Colors.white, 61 | borderRadius: BorderRadius.circular(16), 62 | boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 10, offset: Offset(0, 2))], 63 | ), 64 | child: Column( 65 | crossAxisAlignment: CrossAxisAlignment.start, 66 | children: [ 67 | SizedBox(height: 16), 68 | _buildNumericControls(), 69 | SizedBox(height: 16), 70 | _buildShapeControls(), 71 | ], 72 | ), 73 | ); 74 | } 75 | 76 | Widget _buildNumericControls() { 77 | return Wrap( 78 | spacing: 12, 79 | runSpacing: 12, 80 | children: [ 81 | _buildSliderControl('Node Sep', builder.nodeSeparation, 5, 50, (v) => builder.nodeSeparation = v), 82 | _buildSliderControl('Level Sep', builder.levelSeparation, 5, 100, (v) => builder.levelSeparation = v), 83 | _buildDropdown('Alignment', builder.coordinateAssignment, CoordinateAssignment.values, (v) => builder.coordinateAssignment = v), 84 | _buildDropdown('Layering', builder.layeringStrategy, LayeringStrategy.values, (v) => builder.layeringStrategy = v), 85 | _buildDropdown('Cross Min', builder.crossMinimizationStrategy, CrossMinimizationStrategy.values, (v) => builder.crossMinimizationStrategy = v), 86 | _buildDropdown('Cycle Removal', builder.cycleRemovalStrategy, CycleRemovalStrategy.values, (v) => builder.cycleRemovalStrategy = v), 87 | ], 88 | ); 89 | } 90 | 91 | Widget _buildSliderControl(String label, int value, int min, int max, Function(int) onChanged) { 92 | return Container( 93 | width: 200, 94 | child: Column( 95 | crossAxisAlignment: CrossAxisAlignment.start, 96 | children: [ 97 | Text(label, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500)), 98 | Slider( 99 | value: value.toDouble().clamp(min.toDouble(), max.toDouble()), 100 | min: min.toDouble(), 101 | max: max.toDouble(), 102 | divisions: max - min, 103 | label: value.toString(), 104 | onChanged: (v) => setState(() => onChanged(v.round())), 105 | ), 106 | ], 107 | ), 108 | ); 109 | } 110 | 111 | Widget _buildDropdown(String label, T value, List items, Function(T) onChanged) { 112 | return Container( 113 | width: 160, 114 | child: Column( 115 | crossAxisAlignment: CrossAxisAlignment.start, 116 | children: [ 117 | Text(label, style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500)), 118 | SizedBox(height: 4), 119 | Container( 120 | padding: EdgeInsets.symmetric(horizontal: 12), 121 | decoration: BoxDecoration( 122 | border: Border.all(color: Colors.grey[300]!), 123 | borderRadius: BorderRadius.circular(8), 124 | ), 125 | child: DropdownButtonHideUnderline( 126 | child: DropdownButton( 127 | value: value, 128 | isExpanded: true, 129 | items: items.map((item) => DropdownMenuItem(value: item, child: Text(item.toString().split('.').last, style: TextStyle(fontSize: 12)))).toList(), 130 | onChanged: (v) => setState(() => onChanged(v!)), 131 | ), 132 | ), 133 | ), 134 | ], 135 | ), 136 | ); 137 | } 138 | 139 | Widget _buildShapeControls() { 140 | return Column( 141 | crossAxisAlignment: CrossAxisAlignment.start, 142 | children: [ 143 | Text('Edge Shape', style: TextStyle(fontSize: 12, fontWeight: FontWeight.w500)), 144 | SizedBox(height: 8), 145 | Row( 146 | children: [ 147 | _buildShapeButton('Sharp', builder.bendPointShape is SharpBendPointShape, () => builder.bendPointShape = SharpBendPointShape()), 148 | SizedBox(width: 8), 149 | _buildShapeButton('Curved', builder.bendPointShape is CurvedBendPointShape, () => builder.bendPointShape = CurvedBendPointShape(curveLength: 20)), 150 | SizedBox(width: 8), 151 | _buildShapeButton('Max Curved', builder.bendPointShape is MaxCurvedBendPointShape, () => builder.bendPointShape = MaxCurvedBendPointShape()), 152 | Spacer(), 153 | Row( 154 | children: [ 155 | Text('Post Straighten', style: TextStyle(fontSize: 12)), 156 | Switch( 157 | value: builder.postStraighten, 158 | onChanged: (v) => setState(() => builder.postStraighten = v), 159 | activeThumbColor: Colors.blue, 160 | ), 161 | ], 162 | ), 163 | ], 164 | ), 165 | ], 166 | ); 167 | } 168 | 169 | Widget _buildShapeButton(String text, bool isSelected, VoidCallback onPressed) { 170 | return ElevatedButton( 171 | onPressed: () => setState(onPressed), 172 | child: Text(text, style: TextStyle(fontSize: 11)), 173 | style: ElevatedButton.styleFrom( 174 | backgroundColor: isSelected ? Colors.blue : Colors.grey[100], 175 | foregroundColor: isSelected ? Colors.white : Colors.grey[700], 176 | elevation: isSelected ? 2 : 0, 177 | padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8), 178 | ), 179 | ); 180 | } 181 | 182 | Widget _buildGraphView() { 183 | return Container( 184 | margin: EdgeInsets.fromLTRB(16, 0, 16, 16), 185 | decoration: BoxDecoration( 186 | color: Colors.white, 187 | borderRadius: BorderRadius.circular(16), 188 | boxShadow: [BoxShadow(color: Colors.black12, blurRadius: 10, offset: Offset(0, 2))], 189 | ), 190 | child: ClipRRect( 191 | borderRadius: BorderRadius.circular(16), 192 | child: GraphView.builder( 193 | controller: _controller, 194 | graph: graph, 195 | algorithm: SugiyamaAlgorithm(builder), 196 | paint: Paint() 197 | ..color = Colors.blue[300]! 198 | ..strokeWidth = 2 199 | ..style = PaintingStyle.stroke, 200 | builder: (Node node) { 201 | final nodeId = node.key!.value as int; 202 | return Container( 203 | width: 40, 204 | height: 40, 205 | decoration: BoxDecoration( 206 | gradient: LinearGradient( 207 | colors: [Colors.blue[400]!, Colors.blue[600]!], 208 | begin: Alignment.topLeft, 209 | end: Alignment.bottomRight, 210 | ), 211 | shape: BoxShape.circle, 212 | boxShadow: [BoxShadow(color: Colors.blue[100]!, blurRadius: 8, offset: Offset(0, 2))], 213 | ), 214 | child: Center( 215 | child: Text('$nodeId', style: TextStyle(color: Colors.white, fontWeight: FontWeight.w600, fontSize: 14)), 216 | ), 217 | ); 218 | }, 219 | ), 220 | ), 221 | ); 222 | } 223 | 224 | final Graph graph = Graph(); 225 | SugiyamaConfiguration builder = SugiyamaConfiguration() 226 | ..bendPointShape = CurvedBendPointShape(curveLength: 20) 227 | ..nodeSeparation = 15 228 | ..levelSeparation = 15 229 | ..orientation = SugiyamaConfiguration.ORIENTATION_TOP_BOTTOM; 230 | 231 | void _navigateToRandomNode() { 232 | if (graph.nodes.isEmpty) return; 233 | final randomNode = graph.nodes[r.nextInt(graph.nodes.length)]; 234 | _controller.animateToNode(randomNode.key!); 235 | } 236 | 237 | @override 238 | void initState() { 239 | super.initState(); 240 | _initializeGraph(); 241 | } 242 | 243 | void _initializeGraph() { 244 | // Define edges more concisely 245 | final node1 = Node.Id(1); 246 | final node2 = Node.Id(2); 247 | final node3 = Node.Id(3); 248 | final node4 = Node.Id(4); 249 | final node5 = Node.Id(5); 250 | final node6 = Node.Id(6); 251 | final node8 = Node.Id(7); 252 | final node7 = Node.Id(8); 253 | final node9 = Node.Id(9); 254 | final node10 = Node.Id(10); 255 | final node11 = Node.Id(11); 256 | final node12 = Node.Id(12); 257 | final node13 = Node.Id(13); 258 | final node14 = Node.Id(14); 259 | final node15 = Node.Id(15); 260 | final node16 = Node.Id(16); 261 | final node17 = Node.Id(17); 262 | final node18 = Node.Id(18); 263 | final node19 = Node.Id(19); 264 | final node20 = Node.Id(20); 265 | final node21 = Node.Id(21); 266 | final node22 = Node.Id(22); 267 | final node23 = Node.Id(23); 268 | 269 | graph.addEdge(node1, node13, paint: Paint()..color = Colors.red); 270 | graph.addEdge(node1, node21); 271 | graph.addEdge(node1, node4); 272 | graph.addEdge(node1, node3); 273 | graph.addEdge(node2, node3); 274 | graph.addEdge(node2, node20); 275 | graph.addEdge(node3, node4); 276 | graph.addEdge(node3, node5); 277 | graph.addEdge(node3, node23); 278 | graph.addEdge(node4, node6); 279 | graph.addEdge(node5, node7); 280 | graph.addEdge(node6, node8); 281 | graph.addEdge(node6, node16); 282 | graph.addEdge(node6, node23); 283 | graph.addEdge(node7, node9); 284 | graph.addEdge(node8, node10); 285 | graph.addEdge(node8, node11); 286 | graph.addEdge(node9, node12); 287 | graph.addEdge(node10, node13); 288 | graph.addEdge(node10, node14); 289 | graph.addEdge(node10, node15); 290 | graph.addEdge(node11, node15); 291 | graph.addEdge(node11, node16); 292 | graph.addEdge(node12, node20); 293 | graph.addEdge(node13, node17); 294 | graph.addEdge(node14, node17); 295 | graph.addEdge(node14, node18); 296 | graph.addEdge(node16, node18); 297 | graph.addEdge(node16, node19); 298 | graph.addEdge(node16, node20); 299 | graph.addEdge(node18, node21); 300 | graph.addEdge(node19, node22); 301 | graph.addEdge(node21, node23); 302 | graph.addEdge(node22, node23); 303 | graph.addEdge(node1, node22); 304 | graph.addEdge(node7, node8); 305 | } 306 | } --------------------------------------------------------------------------------