├── img ├── .gitkeep ├── Play.svg ├── Pause.svg ├── SelectOne.svg ├── icons │ ├── Image.svg │ ├── Container.svg │ ├── Service.svg │ ├── Controller.svg │ ├── ReplicationController.svg │ ├── Pod.svg │ ├── Cluster.svg │ ├── Process.svg │ └── Node.svg ├── BackButton.svg ├── SelectMany.svg ├── Refresh.svg ├── Collapse.svg ├── Expand.svg ├── Search.svg ├── LiveData.svg ├── SampleData.svg └── Pin.svg ├── js ├── .gitkeep └── modules │ ├── .gitkeep │ ├── services │ ├── .gitkeep │ ├── inspectNodeService.js │ ├── d3.js │ ├── d3UtilitiesService.js │ ├── viewModelService.js │ └── d3RenderingService.js │ ├── controllers │ ├── .gitkeep │ ├── inspectNode.js │ └── graph.js │ └── directives │ ├── .gitkeep │ └── d3.js ├── less ├── .gitkeep └── graph.less ├── pages ├── .gitkeep ├── inspect.html └── home.html ├── views ├── .gitkeep └── partials │ └── .gitkeep ├── protractor ├── .gitkeep └── smoke.spec.js ├── test └── modules │ ├── controllers │ ├── .gitkeep │ ├── graph.spec.js │ └── inspectNode.spec.js │ ├── directives │ ├── .gitkeep │ └── d3.spec.js │ └── services │ ├── .gitkeep │ ├── viewModelService.spec.js │ ├── inspectNodeService.spec.js │ ├── d3UtilitiesService.spec.js │ └── d3RenderingService.spec.js ├── config └── development.example.json ├── GraphTab.png ├── manifest.json ├── README.md ├── css └── show-details-table.css └── assets ├── legend.json ├── transforms.json └── transforms └── templateTransform.js /img/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /js/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /less/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /pages/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /views/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /js/modules/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /protractor/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /views/partials/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /js/modules/services/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /js/modules/controllers/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /js/modules/directives/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/modules/controllers/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/modules/directives/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/modules/services/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /config/development.example.json: -------------------------------------------------------------------------------- 1 | {} 2 | -------------------------------------------------------------------------------- /GraphTab.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kubernetes-ui/graph/HEAD/GraphTab.png -------------------------------------------------------------------------------- /img/Play.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /img/Pause.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /img/SelectOne.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /img/icons/Image.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /img/BackButton.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /img/icons/Container.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /img/SelectMany.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /img/icons/Service.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /img/icons/Controller.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /img/icons/ReplicationController.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /img/Refresh.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /img/icons/Pod.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /img/Collapse.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /img/Expand.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /img/Search.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /img/LiveData.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "description": "Force-directed graph for visualizing the Kubernetes cluster configuration and scheduling.", 3 | "routes": [ 4 | { 5 | "description": "Force-directed graph visualization.", 6 | "url": "/", 7 | "templateUrl": "components/graph/pages/home.html" 8 | }, 9 | { 10 | "description": "Inspection panel with detailed information about Kubernetes cluster entities.", 11 | "url": "/inspect", 12 | "templateUrl": "components/graph/pages/inspect.html", 13 | "css": "components/graph/css/show-details-table.css" 14 | } 15 | ] 16 | } 17 | -------------------------------------------------------------------------------- /img/icons/Cluster.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /img/SampleData.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/modules/services/viewModelService.spec.js: -------------------------------------------------------------------------------- 1 | /** 2 | Copyright 2015 Google Inc. All rights reserved. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | 'use strict'; 18 | 19 | describe("View model service", function() { it("should work as intended", function() {}); }); 20 | -------------------------------------------------------------------------------- /test/modules/controllers/graph.spec.js: -------------------------------------------------------------------------------- 1 | /** 2 | Copyright 2015 Google Inc. All rights reserved. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | 'use strict'; 18 | 19 | describe("Graph controller", function() { 20 | 21 | beforeEach(module("kubernetesApp.components.graph")); 22 | 23 | it("should work as intended", function() {}); 24 | }); 25 | -------------------------------------------------------------------------------- /img/Pin.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | pin6 5 | Created with Sketch. 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /img/icons/Process.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /img/icons/Node.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /test/modules/services/inspectNodeService.spec.js: -------------------------------------------------------------------------------- 1 | /** 2 | Copyright 2015 Google Inc. All rights reserved. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | 'use strict'; 18 | 19 | describe("Inspect node service", function() { 20 | var inspectNodeService; 21 | 22 | beforeEach(module('kubernetesApp.components.graph.services')); 23 | beforeEach(inject(function(_inspectNodeService_) { inspectNodeService = _inspectNodeService_; })); 24 | 25 | it("should set and get data as intended", function() { 26 | var data = { 27 | 'name': 'pod', 28 | 'id': 1 29 | }; 30 | inspectNodeService.setDetailData(data); 31 | var getData = inspectNodeService.getDetailData(); 32 | expect(data).toEqual(getData); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /js/modules/services/inspectNodeService.js: -------------------------------------------------------------------------------- 1 | /** 2 | Copyright 2015 Google Inc. All rights reserved. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | /**========================================================= 18 | * Module: Graph 19 | * Visualizer for force directed graph 20 | * This is a service that shares node detals data among controllers. 21 | =========================================================*/ 22 | 23 | (function() { 24 | 'use strict'; 25 | 26 | var inspectNodeService = function() { 27 | var nodeDetails = null; 28 | var setDetailData = function(data) { nodeDetails = data; }; 29 | 30 | var getDetailData = function() { return nodeDetails; }; 31 | 32 | return { 33 | 'setDetailData': setDetailData, 34 | 'getDetailData': getDetailData 35 | }; 36 | }; 37 | 38 | angular.module('kubernetesApp.components.graph.services', []).factory('inspectNodeService', inspectNodeService); 39 | 40 | })(); 41 | -------------------------------------------------------------------------------- /pages/inspect.html: -------------------------------------------------------------------------------- 1 | 16 |
17 |
18 | 19 |
20 | 21 | 22 | 23 | 24 |

25 | {{element}} 26 |

27 |
28 |
29 | 30 |
31 |
32 |
33 |
34 |
35 |
36 | -------------------------------------------------------------------------------- /js/modules/controllers/inspectNode.js: -------------------------------------------------------------------------------- 1 | /** 2 | Copyright 2015 Google Inc. All rights reserved. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | /**========================================================= 18 | * Module: Graph 19 | * Visualizer for force directed graph 20 | =========================================================*/ 21 | (function() { 22 | 'use strict'; 23 | 24 | angular.module('kubernetesApp.components.graph') 25 | .controller('InspectNodeCtrl', [ 26 | '$scope', 27 | 'inspectNodeService', 28 | '$location', 29 | function($scope, inspectNodeService, $location) { 30 | var nodeDetail = inspectNodeService.getDetailData(); 31 | $scope.element = nodeDetail.id; 32 | $scope.metadata = nodeDetail.metadata; 33 | 34 | $scope.backToGraph = function() { 35 | $location.path('/graph'); 36 | $scope.$apply(); 37 | }; 38 | } 39 | ]); 40 | 41 | })(); 42 | -------------------------------------------------------------------------------- /js/modules/services/d3.js: -------------------------------------------------------------------------------- 1 | /** 2 | Copyright 2015 Google Inc. All rights reserved. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | angular.module('kubernetesApp.components.graph') 18 | .factory('d3Service', [ 19 | '$document', 20 | '$q', 21 | '$rootScope', 22 | function($document, $q, $rootScope) { 23 | var d = $q.defer(); 24 | function onScriptLoad() { 25 | // Load client in the browser 26 | $rootScope.$apply(function() { d.resolve(window.d3); }); 27 | } 28 | // Create a script tag with d3 as the source 29 | // and call our onScriptLoad callback when it 30 | // has been loaded 31 | var scriptTag = $document[0].createElement('script'); 32 | scriptTag.type = 'text/javascript'; 33 | scriptTag.async = true; 34 | scriptTag.src = 'vendor/d3/d3.min.js'; 35 | scriptTag.onreadystatechange = function() { 36 | if (this.readyState == 'complete') onScriptLoad(); 37 | }; 38 | scriptTag.onload = onScriptLoad; 39 | 40 | var s = $document[0].getElementsByTagName('body')[0]; 41 | s.appendChild(scriptTag); 42 | 43 | return { 44 | d3: function() { return d.promise; } 45 | }; 46 | } 47 | ]); 48 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Graph Component for Kubernetes WebUI 2 | 3 | This is the Graph component for the Kubernetes UI. It uses the [d3 Force Layout](https://github.com/mbostock/d3/wiki/Force-Layout) to expose the structure and organization of the cluster, creating renderings like this one: 4 | 5 | ![Screenshot](GraphTab.png) 6 | 7 | It contains a legend that lets the user filter the types of objects displayed. Modifier keys let the user zoom the graph, and select or pin individual objects. Objects can also be inspected to display their available properties. 8 | 9 | ## Data Source 10 | By default, the data displayed by the Graph tab is collected from the Kubernetes api server and the Docker daemons, and assembled into a single JSON document exposed on a REST endpoint by the cluster-insight container available [here](https://registry.hub.docker.com/u/kubernetes/cluster-insight/) on DockerHub. Installation and usage instructions for the cotainer are provided [here](https://github.com/google/cluster-insight) on GitHub. 11 | 12 | The data are cached by the container and refreshed periodically to throttle the load on the cluster. The application can poll the container for the document continuously or on demand. When new contents are retrieved from the container, the application transforms them into the shape displayed on the canvas using a pluggable transform engine that loads transforms from the assets folder. The default transform is declarative; it interprets JSON documents loaded from the same location. 13 | 14 | Canned data is also available for use without cluster-insight. It's selectable using the 'cloud' button located above the canvas. The canned data is served from a file in the assets folder. 15 | 16 | 17 | [![Analytics](https://kubernetes-site.appspot.com/UA-36037335-10/GitHub/www/master/components/graph/GraphTab.png?pixel)]() 18 | 19 | 20 | [![Analytics](https://kubernetes-site.appspot.com/UA-36037335-10/GitHub/www/master/components/graph/README.md?pixel)]() 21 | -------------------------------------------------------------------------------- /test/modules/controllers/inspectNode.spec.js: -------------------------------------------------------------------------------- 1 | /** 2 | Copyright 2015 Google Inc. All rights reserved. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | 'use strict'; 18 | 19 | describe("Inspect node controller", function() { 20 | var inspectNodeService = {}; 21 | var scope, location, controller; 22 | var mockNodeDetail = { 23 | 'id': 1, 24 | 'metadata': 'data' 25 | }; 26 | // Work around to get ngLodash correctly injected. 27 | beforeEach(function() { 28 | angular.module('testModule', ['ngLodash', 'kubernetesApp.components.graph', 'kubernetesApp.config']); 29 | }); 30 | 31 | beforeEach(module('testModule')); 32 | 33 | beforeEach(inject(function(_inspectNodeService_, $controller, $location, $rootScope) { 34 | inspectNodeService = _inspectNodeService_; 35 | // Mock the node detail data returned by the service. 36 | inspectNodeService.setDetailData(mockNodeDetail); 37 | scope = $rootScope.$new(); 38 | location = $location; 39 | controller = $controller('InspectNodeCtrl', {$scope: scope, $location: location}); 40 | })); 41 | 42 | it("should work as intended", function() { 43 | // Test if the controller sets the correct model values. 44 | expect(scope.element).toEqual(mockNodeDetail.id); 45 | expect(scope.metadata).toEqual(mockNodeDetail.metadata); 46 | 47 | // Test if the controller changed the location correctly. 48 | scope.backToGraph(); 49 | expect(location.path()).toEqual('/graph'); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /js/modules/directives/d3.js: -------------------------------------------------------------------------------- 1 | /** 2 | Copyright 2015 Google Inc. All rights reserved. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | /**========================================================= 18 | * Module: Graph 19 | * Visualizer for force directed graph. 20 | * This is a directive that uses d3 to generate an svg 21 | * element. 22 | =========================================================*/ 23 | 24 | angular.module('kubernetesApp.components.graph') 25 | .directive('d3Visualization', [ 26 | 'd3Service', 27 | 'd3RenderingService', 28 | function(d3Service, d3RenderingService) { 29 | return { 30 | restrict: 'E', 31 | link: function(scope, element, attrs) { 32 | scope.$watch('viewModelService.viewModel.version', function(newValue, oldValue) { 33 | if (!window.d3) { 34 | d3Service.d3().then(d3Rendering); 35 | } else { 36 | d3Rendering(); 37 | } 38 | }); 39 | 40 | scope.$watch('selectionIdList', function(newValue, oldValue) { 41 | if (newValue !== undefined) { 42 | // The d3Rendering.nodeSelection() method expects a set of objects, each with an id property. 43 | var nodes = new Set(); 44 | 45 | newValue.forEach(function(e) { nodes.add({id: e}); }); 46 | 47 | d3Rendering.nodeSelection(nodes); 48 | } 49 | }); 50 | 51 | var d3Rendering = d3RenderingService.rendering().controllerScope(scope).directiveElement(element[0]); 52 | } 53 | }; 54 | } 55 | ]); 56 | -------------------------------------------------------------------------------- /less/graph.less: -------------------------------------------------------------------------------- 1 | .graph { 2 | .edgelabel { 3 | font: 12px sans-serif; 4 | paint-order: stroke; 5 | stroke: #ffffff; 6 | stroke-width: 3px; 7 | } 8 | 9 | .node text { 10 | pointer-events: none; 11 | font: 12px sans-serif; 12 | paint-order: stroke; 13 | stroke: #ffffff; 14 | stroke-width: 3px; 15 | } 16 | } 17 | 18 | .d3-context-menu { 19 | position: absolute; 20 | display: none; 21 | background-color: #f2f2f2; 22 | border-radius: 4px; 23 | 24 | font-family: Arial, sans-serif; 25 | font-size: 14px; 26 | min-width: 150px; 27 | border: 1px solid #d4d4d4; 28 | 29 | z-index:1200; 30 | } 31 | 32 | .d3-context-menu ul { 33 | list-style-type: none; 34 | margin: 4px 0px; 35 | padding: 0px; 36 | cursor: default; 37 | } 38 | 39 | .d3-context-menu ul li { 40 | padding: 4px 16px; 41 | } 42 | 43 | .d3-context-menu ul li:hover { 44 | background-color: #4677f8; 45 | color: #fefefe; 46 | } 47 | 48 | .details-table { 49 | width: 100%; 50 | border-collapse: collapse; 51 | } 52 | 53 | .details-table-content-row { 54 | width: 100%; 55 | } 56 | 57 | .details-table-content-tag { 58 | padding: 5px; 59 | white-space: pre-wrap; 60 | margin: 0px; 61 | } 62 | 63 | .details-table-content-value { 64 | padding: 5px; 65 | word-break: break-all; 66 | white-space: pre-wrap; 67 | margin: 0px; 68 | } 69 | 70 | .sidenav-card { 71 | margin: 0; 72 | } 73 | 74 | .sidenav-frame { 75 | padding: 0; 76 | } 77 | 78 | .legend-table { 79 | width: 100%; 80 | border-collapse: collapse; 81 | table-layout: fixed; 82 | margin-top: 6px; 83 | margin-bottom: 6px; 84 | } 85 | 86 | .legend-table-content-row { 87 | width: 100%; 88 | } 89 | 90 | .legend-table-icon { 91 | width: 34px; 92 | height: 34px; 93 | padding: 5px; 94 | } 95 | 96 | .legend-table-text { 97 | font-size: 12px; 98 | padding: 5px; 99 | word-break: break-all; 100 | vertical-align: center; 101 | } 102 | 103 | .bold { 104 | font-weight: 700; 105 | } 106 | 107 | .gray { 108 | color: gray; 109 | } 110 | 111 | .pin-cursor { 112 | cursor: url(../../components/graph/img/Pin.svg) 6 21, auto; 113 | } 114 | 115 | .zoom-cursor { 116 | cursor: -webkit-zoom-in; 117 | } 118 | -------------------------------------------------------------------------------- /css/show-details-table.css: -------------------------------------------------------------------------------- 1 | /** 2 | * Angular directive to convert JSON into human readable table. Inspired by https://github.com/marianoguerra/json.human.js. 3 | * @version v1.2.1 - 2014-12-22 4 | * @link https://github.com/yaru22/angular-json-human 5 | * @author Brian Park 6 | * @license MIT License, http://www.opensource.org/licenses/MIT 7 | */ 8 | /** 9 | * DISCLAIMER: This CSS is copied from https://github.com/marianoguerra/json.human.js 10 | */ 11 | 12 | .jh-root, 13 | .jh-type-object, 14 | .jh-type-array, 15 | .jh-key, 16 | .jh-value, 17 | .jh-root tr { 18 | font-size: 14px; 19 | -webkit-box-sizing: border-box; /* Safari/Chrome, other WebKit */ 20 | -moz-box-sizing: border-box; /* Firefox, other Gecko */ 21 | box-sizing: border-box; /* Opera/IE 8+ */ 22 | } 23 | 24 | .jh-key, 25 | .jh-value { 26 | margin: 0; 27 | padding: 0.6em; 28 | } 29 | 30 | .jh-value { 31 | border-left: 1px solid #ddd; 32 | } 33 | 34 | .jh-type-bool, 35 | .jh-type-number { 36 | text-align: center; 37 | color: rgba(0,0,0,0.87); 38 | } 39 | 40 | .jh-type-string { 41 | color: rgba(0,0,0,0.87); 42 | } 43 | 44 | .jh-array-key { 45 | font-size: small; 46 | text-align: center; 47 | } 48 | 49 | .jh-object-key, 50 | .jh-array-key { 51 | color: #444; 52 | vertical-align: top; 53 | } 54 | 55 | .jh-type-object > tbody > tr:nth-child(odd), 56 | .jh-type-array > tbody > tr:nth-child(odd) { 57 | background-color: #f5f5f5; 58 | } 59 | 60 | .jh-type-object > tbody > tr:nth-child(even), 61 | .jh-type-array > tbody > tr:nth-child(even) { 62 | background-color: #fff; 63 | } 64 | 65 | .jh-type-object, 66 | .jh-type-array { 67 | width: 100%; 68 | border-collapse: collapse; 69 | } 70 | 71 | .jh-root { 72 | border: 1px solid #ccc; 73 | margin: 0.2em; 74 | } 75 | 76 | th.jh-key { 77 | text-align: left; 78 | } 79 | 80 | .jh-type-object > tbody > tr, 81 | .jh-type-array > tbody > tr { 82 | border: 1px solid #ddd; 83 | border-bottom: none; 84 | } 85 | 86 | .jh-type-object > tbody > tr:last-child, 87 | .jh-type-array > tbody > tr:last-child { 88 | border-bottom: 1px solid #ddd; 89 | } 90 | 91 | .jh-type-object > tbody > tr:hover, 92 | .jh-type-array > tbody > tr:hover { 93 | border: 1px solid rgb(63,81,181); 94 | } 95 | 96 | .jh-empty { 97 | color: #999; 98 | font-size: small; 99 | } 100 | -------------------------------------------------------------------------------- /assets/legend.json: -------------------------------------------------------------------------------- 1 | { 2 | "nodes": { 3 | "Container": { 4 | "style": { 5 | "size": [24, 24], 6 | "radius": 12, 7 | "icon": "components/graph/img/icons/Container.svg", 8 | "fill": "cornflowerblue", 9 | "stroke": "dimgray" 10 | }, 11 | "selected": true, 12 | "available": true 13 | }, 14 | "Cluster": { 15 | "style": { 16 | "size": [34, 34], 17 | "radius": 17, 18 | "icon": "components/graph/img/icons/Cluster.svg", 19 | "fill": "#D32F2F", 20 | "stroke": "dimgray" 21 | }, 22 | "selected": false, 23 | "available": true 24 | }, 25 | "Node": { 26 | "style": { 27 | "size": [34, 34], 28 | "radius": 17, 29 | "icon": "components/graph/img/icons/Node.svg", 30 | "fill": "#FF4D81", 31 | "stroke": "dimgray" 32 | }, 33 | "selected": false, 34 | "available": true 35 | }, 36 | "Process": { 37 | "style": { 38 | "size": [24, 24], 39 | "radius": 12, 40 | "icon": "components/graph/img/icons/Process.svg", 41 | "fill": "#FF9800", 42 | "stroke": "dimgray" 43 | }, 44 | "selected": false, 45 | "available": true 46 | }, 47 | "Service": { 48 | "style": { 49 | "size": [30, 30], 50 | "radius": 15, 51 | "icon": "components/graph/img/icons/Service.svg", 52 | "fill": "#7C4DFF", 53 | "stroke": "dimgray" 54 | }, 55 | "selected": true, 56 | "available": true 57 | }, 58 | "ReplicationController": { 59 | "displayName": "Replication Controller", 60 | "style": { 61 | "size": [30, 30], 62 | "radius": 15, 63 | "icon": "components/graph/img/icons/ReplicationController.svg", 64 | "fill": "#DE2AFB", 65 | "stroke": "dimgray" 66 | }, 67 | "selected": true, 68 | "available": true 69 | }, 70 | "Pod": { 71 | "style": { 72 | "size": [30, 30], 73 | "radius": 15, 74 | "icon": "components/graph/img/icons/Pod.svg", 75 | "fill": "#E91E63", 76 | "stroke": "dimgray" 77 | }, 78 | "selected": true, 79 | "available": true 80 | }, 81 | "Image": { 82 | "style": { 83 | "size": [24, 24], 84 | "radius": 12, 85 | "icon": "components/graph/img/icons/Image.svg", 86 | "fill": "#D1C4E9", 87 | "stroke": "dimgray" 88 | }, 89 | "selected": false, 90 | "available": true 91 | } 92 | }, 93 | "links": { 94 | "contains": { 95 | "available": true, 96 | "style": { 97 | "dash": "1, 1", 98 | "width": 1, 99 | "stroke": "gray", 100 | "distance": 10 101 | } 102 | }, 103 | "runs": { 104 | "available": true, 105 | "style": { 106 | "dash": "5, 5", 107 | "width": 1, 108 | "stroke": "#FF4D81", 109 | "distance": 10 110 | } 111 | }, 112 | "balances": { 113 | "available": true, 114 | "style": { 115 | "width": 1, 116 | "stroke": "#7C4DFF", 117 | "dash": "5, 5", 118 | "distance": 15 119 | } 120 | }, 121 | "uses": { 122 | "available": true, 123 | "style": { 124 | "width": 1, 125 | "stroke": "#D1C4E9", 126 | "dash": "5, 5", 127 | "distance": 15 128 | } 129 | }, 130 | "monitors": { 131 | "available": true, 132 | "style": { 133 | "width": 1, 134 | "stroke": "#DE2AFB", 135 | "dash": "5, 5", 136 | "distance": 15 137 | } 138 | } 139 | } 140 | } 141 | -------------------------------------------------------------------------------- /test/modules/directives/d3.spec.js: -------------------------------------------------------------------------------- 1 | /** 2 | Copyright 2015 Google Inc. All rights reserved. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | 'use strict'; 18 | 19 | describe('D3 directive', function() { 20 | var $compile; 21 | var $rootScope; 22 | var viewModelService; 23 | 24 | var MOCK_SAMPLE_DATA = [{ 25 | 'nodes': [ 26 | {'name': 'service: guestbook', 'radius': 16, 'fill': 'olivedrab', 'id': 5}, 27 | {'name': 'pod: guestbook-controller', 'radius': 20, 'fill': 'palegoldenrod', 'id': 2}, 28 | ], 29 | 'links': [], 30 | 'configuration': {'settings': {'clustered': false, 'showEdgeLabels': true, 'showNodeLabels': true}} 31 | }]; 32 | 33 | // Work around to get ngLodash correctly injected. 34 | beforeEach(function() { angular.module('testModule', ['ngLodash', 'kubernetesApp.components.graph']); }); 35 | 36 | beforeEach(module('testModule')); 37 | 38 | beforeEach(inject(function(_$compile_, _$rootScope_, _viewModelService_) { 39 | $compile = _$compile_; 40 | $rootScope = _$rootScope_; 41 | viewModelService = _viewModelService_; 42 | })); 43 | 44 | it('should replace the element with the appropriate svg content in response to the viewModel being set', function() { 45 | // Compile some HTML containing the directive. 46 | var element = $compile('
')($rootScope); 47 | 48 | $rootScope.viewModelService = viewModelService; 49 | 50 | // Test that the element hasn't been compiled yet. 51 | expect(element.html()).toEqual(''); 52 | 53 | // Request the viewModelService to update the view model with the specified data. 54 | viewModelService.setViewModel(MOCK_SAMPLE_DATA[0]); 55 | 56 | // Test that the element still hasn't been compiled yet. 57 | expect(element.html()).toEqual(''); 58 | 59 | // Fire all the watches. 60 | $rootScope.$digest(); 61 | 62 | // Test that the element has been compiled and contains the svg content. 63 | expect(element.html()).toContain('')($rootScope); 71 | 72 | $rootScope.viewModelService = viewModelService; 73 | 74 | // Request the viewModelService to update the view model with the specified data. No initial selections. 75 | viewModelService.setViewModel(MOCK_SAMPLE_DATA[0]); 76 | 77 | // Fire all the watches. 78 | $rootScope.$digest(); 79 | 80 | // Test that each node has an opacity of 1. 81 | var nodeList = element[0].querySelectorAll("g > g"); 82 | 83 | for (var i = 0; i < nodeList.length; i++) { 84 | var node = angular.element(nodeList[i]); 85 | if (node.style !== undefined) { 86 | expect(node.style).toEqual('opacity: 1;'); 87 | } 88 | } 89 | 90 | // Set a new selection id list that should trigger a watch. 91 | $rootScope.selectionIdList = [2]; 92 | 93 | // Fire all the watches. 94 | $rootScope.$digest(); 95 | 96 | // Test that at least one node has an opacity of 1 and at least one node has an opacity of less than 1. 97 | var foundOpacityOfOne = false; 98 | var foundOpacityLessThanOne = false; 99 | nodeList = element[0].querySelectorAll("g > g"); 100 | 101 | for (var i = 0; i < nodeList.length; i++) { 102 | if (nodeList[i].getAttribute("style") === "opacity: 1;") { 103 | foundOpacityOfOne = true; 104 | } else if (nodeList[i].getAttribute("style") === "opacity: 0.2;") { 105 | foundOpacityLessThanOne = true; 106 | } 107 | } 108 | 109 | expect(foundOpacityOfOne).toBeTruthy(); 110 | expect(foundOpacityLessThanOne).toBeTruthy(); 111 | }); 112 | }); 113 | -------------------------------------------------------------------------------- /test/modules/services/d3UtilitiesService.spec.js: -------------------------------------------------------------------------------- 1 | /** 2 | Copyright 2015 Google Inc. All rights reserved. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | 'use strict'; 18 | 19 | describe('D3 utilities service', function() { 20 | var d3UtilitiesService; 21 | 22 | // Work around to get ngLodash correctly injected. 23 | beforeEach(function() { angular.module('testModule', ['ngLodash', 'kubernetesApp.components.graph']); }); 24 | 25 | beforeEach(module('testModule')); 26 | 27 | beforeEach(inject(function(_d3UtilitiesService_) { d3UtilitiesService = _d3UtilitiesService_; })); 28 | 29 | it('should generate starting positions within specified radius of origin', function() { 30 | // Get random starting positions for 10,000 nodes. 31 | var maxDistance = 0; 32 | 33 | for (var i = 0; i < 50000; i++) { 34 | var startingPosition = d3UtilitiesService.getRandomStartingPosition(200); 35 | var x = startingPosition[0]; 36 | var y = startingPosition[1]; 37 | var distance = Math.sqrt(x * x + y * y); 38 | 39 | maxDistance = Math.max(distance, maxDistance); 40 | } 41 | 42 | // Test that all nodes are positioned within 200 pixels (the specified radius) of the origin. 43 | expect(maxDistance).toBeLessThan(200); 44 | }); 45 | 46 | it('should determine whether nodes are neighbors', function() { 47 | var linkedByIndex = {'0,0': 1, '1,1': 1, '2,2': 1, '3,3': 1, '4,4': 1, '0,3': 1, '3,4': 1}; 48 | 49 | // Test that 0 does not neighbor 3 when selectionHops == 0. 50 | var isNeighboring = d3UtilitiesService.neighboring({index: 0}, {index: 3}, linkedByIndex, 0); 51 | expect(isNeighboring).toBeFalsy(); 52 | 53 | // Test that 3 does not neighbor 4 when selectionHops == 0. 54 | isNeighboring = d3UtilitiesService.neighboring({index: 3}, {index: 4}, linkedByIndex, 0); 55 | expect(isNeighboring).toBeFalsy(); 56 | 57 | // Test that 0 does not neighbor 4 when selectionHops == 0. 58 | isNeighboring = d3UtilitiesService.neighboring({index: 0}, {index: 4}, linkedByIndex, 0); 59 | expect(isNeighboring).toBeFalsy(); 60 | 61 | // Test that 0 does neighbor 3 when selectionHops == 1. 62 | isNeighboring = d3UtilitiesService.neighboring({index: 0}, {index: 3}, linkedByIndex, 1); 63 | expect(isNeighboring).toBeTruthy(); 64 | 65 | // Test that 3 does neighbor 4 when selectionHops == 1. 66 | isNeighboring = d3UtilitiesService.neighboring({index: 3}, {index: 4}, linkedByIndex, 1); 67 | expect(isNeighboring).toBeTruthy(); 68 | 69 | // Test that 0 still does not neighbor 4 when selectionHops == 1. 70 | isNeighboring = d3UtilitiesService.neighboring({index: 0}, {index: 4}, linkedByIndex, 1); 71 | expect(isNeighboring).toBeFalsy(); 72 | }); 73 | 74 | it('should find matches in search set', function() { 75 | var searchSet = new Set(); 76 | var itemOne = {id: '1'}; 77 | var itemTwo = {id: '2'}; 78 | var itemThree = {id: '3'}; 79 | 80 | searchSet.add(itemOne); 81 | searchSet.add({id: '2'}); 82 | 83 | // Test that a match is returned when the actual object is in the set. 84 | expect(d3UtilitiesService.setHas(searchSet, itemOne)).toBeTruthy(); 85 | 86 | // Test that a match is returned when an item with the same id is in the set, even if it is not the actual object. 87 | expect(d3UtilitiesService.setHas(searchSet, itemTwo)).toBeTruthy(); 88 | 89 | // Test that a match is not returned if the object is not in the set and no item in the set has the same id. 90 | expect(d3UtilitiesService.setHas(searchSet, itemThree)).toBeFalsy(); 91 | }); 92 | 93 | it('should properly build clusters', function() { 94 | var nodes = [ 95 | {cluster: 0, radius: 5}, 96 | {cluster: 0, radius: 10}, 97 | {cluster: 0, radius: 15}, 98 | {cluster: 1, radius: 3}, 99 | {cluster: 1, radius: 9}, 100 | {cluster: 1, radius: 6}, 101 | {cluster: 2, radius: 6}, 102 | {cluster: 2, radius: 4}, 103 | {cluster: 2, radius: 2}, 104 | ]; 105 | 106 | var builtClusters = d3UtilitiesService.buildClusters(nodes); 107 | 108 | // Test that 3 clusters are identified. 109 | expect(builtClusters.clusters.length).toEqual(3); 110 | 111 | // Test that the node with the largest radius in cluster 0 is the one with a radius of 15. 112 | expect(builtClusters.clusters[0].radius).toEqual(15); 113 | 114 | // Test that the node with the largest radius in cluster 1 is the one with a radius of 9. 115 | expect(builtClusters.clusters[1].radius).toEqual(9); 116 | 117 | // Test that the node with the largest radius in cluster 2 is the one with a radius of 6. 118 | expect(builtClusters.clusters[2].radius).toEqual(6); 119 | 120 | // Test that the maximum radius identified is 15. 121 | expect(builtClusters.maxRadius).toEqual(15); 122 | }); 123 | }); 124 | -------------------------------------------------------------------------------- /assets/transforms.json: -------------------------------------------------------------------------------- 1 | { 2 | "directory": [ 3 | { 4 | "name": "Default", 5 | "script": "templateTransform.js", 6 | "data": { 7 | "nodeMaps": [ 8 | { 9 | "properties": { 10 | "type": "$.type", 11 | "name": "$.annotations.label", 12 | "metadata": "$.properties" 13 | } 14 | }, 15 | { 16 | "filter": { 17 | "eval": "('%s' == 'Cluster')", 18 | "args": [ 19 | "$.type" 20 | ] 21 | }, 22 | "properties": { 23 | "tags": { 24 | } 25 | } 26 | }, 27 | { 28 | "filter": { 29 | "eval": "('%s' == 'Container')", 30 | "args": [ 31 | "$.type" 32 | ] 33 | }, 34 | "properties": { 35 | "tags": { 36 | "HostConfig": "$.properties.HostConfig", 37 | "State": "$properties.State", 38 | "labels": "$.properties.metadata.labels" 39 | } 40 | } 41 | }, 42 | { 43 | "filter": { 44 | "eval": "('%s' == 'Image')", 45 | "args": [ 46 | "$.type" 47 | ] 48 | }, 49 | "properties": { 50 | "tags": { 51 | "Config": "$.properties.Config", 52 | "labels": "$.properties.metadata.labels" 53 | } 54 | } 55 | }, 56 | { 57 | "filter": { 58 | "eval": "('%s' == 'Node')", 59 | "args": [ 60 | "$.type" 61 | ] 62 | }, 63 | "properties": { 64 | "tags": { 65 | "status": "$.properties.status", 66 | "labels": "$.properties.metadata.labels" 67 | } 68 | } 69 | }, 70 | { 71 | "filter": { 72 | "eval": "('%s' == 'Pod')", 73 | "args": [ 74 | "$.type" 75 | ] 76 | }, 77 | "properties": { 78 | "tags": { 79 | "status": "$.properties.status", 80 | "labels": "$.properties.metadata.labels" 81 | } 82 | } 83 | }, 84 | { 85 | "filter": { 86 | "eval": "('%s' == 'Process')", 87 | "args": [ 88 | "$.type" 89 | ] 90 | }, 91 | "properties": { 92 | "tags": { 93 | "%CPU": "$.properties.%CPU", 94 | "%MEM": "$.properties.%MEM", 95 | "COMMAND": "$.properties.COMMAND", 96 | "PID": "$.properties.PID", 97 | "RSS": "$.properties.RSS", 98 | "START": "$.properties.START", 99 | "STAT": "$.properties.STAT", 100 | "TIME": "$.properties.TIME", 101 | "TTY": "$.properties.TTY", 102 | "USER": "$.properties.USER", 103 | "VSZ": "$.properties.VSZ", 104 | "labels": "$.properties.metadata.labels" 105 | } 106 | } 107 | }, 108 | { 109 | "filter": { 110 | "eval": "('%s' == 'ReplicationController')", 111 | "args": [ 112 | "$.type" 113 | ] 114 | }, 115 | "properties": { 116 | "tags": { 117 | "replicas": "$.properties.spec.replicas", 118 | "selector": "$.properties.spec.selector", 119 | "labels": "$.properties.metadata.labels" 120 | } 121 | } 122 | }, 123 | { 124 | "filter": { 125 | "eval": "('%s' == 'Service')", 126 | "args": [ 127 | "$.type" 128 | ] 129 | }, 130 | "properties": { 131 | "tags": { 132 | "spec": "$.properties.spec", 133 | "status": "$.properties.status", 134 | "labels": "$.properties.metadata.labels" 135 | } 136 | } 137 | } 138 | ], 139 | "edgeMaps": [ 140 | { 141 | "properties": { 142 | "type": "$.type", 143 | "label": "$.annotations.label", 144 | "source": "$.source", 145 | "target": "$.target", 146 | "metadata": "$.properties" 147 | } 148 | }, 149 | { 150 | "filter": { 151 | "eval": "('%s' == 'loadBalances')", 152 | "args": [ 153 | "$.type" 154 | ] 155 | }, 156 | "properties": { 157 | "type": "balances", 158 | "label": "balances" 159 | } 160 | }, 161 | { 162 | "filter": { 163 | "eval": "('%s' == 'createdFrom')", 164 | "args": [ 165 | "$.type" 166 | ] 167 | }, 168 | "properties": { 169 | "type": "uses", 170 | "label": "uses" 171 | } 172 | } 173 | ] 174 | } 175 | } 176 | ] 177 | } 178 | -------------------------------------------------------------------------------- /protractor/smoke.spec.js: -------------------------------------------------------------------------------- 1 | /** 2 | Copyright 2015 Google Inc. All rights reserved. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | describe('Kubernetes UI Graph', function() { 18 | it('should have all the expected components loaded', function() { 19 | browser.get('http://localhost:8000'); 20 | expect(browser.getTitle()).toEqual('Kubernetes UI'); 21 | 22 | // Navigate to the graph page. 23 | var graphTab = element(by.id('tab_002')); 24 | expect(graphTab).toBeDefined(); 25 | graphTab.click(); 26 | expect(browser.getLocationAbsUrl()).toBe('/graph/'); 27 | 28 | // Verify if the control action icons have been loaded. 29 | var expandCollapse = element(by.id('ExpandCollapse')); 30 | expect(expandCollapse).toBeDefined(); 31 | var toggleSelect = element(by.id('ToggleSelect')); 32 | expect(toggleSelect).toBeDefined(); 33 | var toggleSource = element(by.id('ToggleSource')); 34 | expect(toggleSource).toBeDefined(); 35 | var pollOnce = element(by.id('PollOnce')); 36 | expect(pollOnce).toBeDefined(); 37 | 38 | // Use mock data to ease testing. 39 | toggleSource.click(); 40 | // Just pull once to get a stable graph. 41 | pollOnce.click(); 42 | 43 | // Make sure the svg is drawn. 44 | var svg = element(by.css('svg')); 45 | expect(svg).toBeDefined(); 46 | }); 47 | 48 | it('should have all the details pane working correctly', function() { 49 | browser.get('http://localhost:8000'); 50 | expect(browser.getTitle()).toEqual('Kubernetes UI'); 51 | 52 | // Navigate to the graph page. 53 | var graphTab = element(by.id('tab_002')); 54 | expect(graphTab).toBeDefined(); 55 | graphTab.click(); 56 | expect(browser.getLocationAbsUrl()).toBe('/graph/'); 57 | 58 | var toggleBtn = element(by.id('toggleDetails')); 59 | expect(toggleBtn).toBeDefined(); 60 | expect(element(by.repeater('type in getLegendLinkTypes()'))).toBeDefined(); 61 | 62 | var details = element(by.id('details')); 63 | expect(details).toBeDefined(); 64 | expect(details.isDisplayed()).toBe(false); 65 | 66 | toggleBtn.click(); 67 | expect(details.isDisplayed()).toBe(true); 68 | }); 69 | 70 | it('should have all the graph working correctly', function() { 71 | browser.get('http://localhost:8000'); 72 | expect(browser.getTitle()).toEqual('Kubernetes UI'); 73 | 74 | // Navigate to the graph page. 75 | var graphTab = element(by.id('tab_002')); 76 | expect(graphTab).toBeDefined(); 77 | graphTab.click(); 78 | expect(browser.getLocationAbsUrl()).toBe('/graph/'); 79 | 80 | var svg = element(by.css('d3-visualization svg')); 81 | expect(svg).toBeDefined(); 82 | 83 | // Make sure the graph is drawn with necessary components. 84 | expect(element(by.css('d3-visualization svg marker'))).toBeDefined(); 85 | expect(element(by.css('d3-visualization svg text'))).toBeDefined(); 86 | expect(element(by.css('d3-visualization svg path'))).toBeDefined(); 87 | expect(element(by.css('d3-visualization svg image'))).toBeDefined(); 88 | expect(element(by.css('d3-visualization svg circle'))).toBeDefined(); 89 | 90 | var toggleSource = element(by.id('ToggleSource')); 91 | expect(toggleSource).toBeDefined(); 92 | var pollOnce = element(by.id('PollOnce')); 93 | expect(pollOnce).toBeDefined(); 94 | 95 | // Use mock data to ease testing. 96 | toggleSource.click(); 97 | // Just pull once to get a stable graph. 98 | pollOnce.click(); 99 | 100 | // Add a custom locator to match on the d3 data backing the circle element. 101 | by.addLocator('datumIdMatches', function(datumId, opt_parentElement, opt_rootSelector) { 102 | var matchingCircles = []; 103 | 104 | window.d3.selectAll('circle').each(function(d) { 105 | if (d && d.id === datumId) { 106 | matchingCircles.push(this); 107 | } 108 | }); 109 | 110 | return matchingCircles; 111 | }); 112 | 113 | // This id matches a node defined in /www/master/shared/assets/sampleData1.json. 114 | var firstNode = element(by.datumIdMatches('Pod:redis-slave-controller-vi7hv')); 115 | expect(firstNode).toBeDefined(); 116 | 117 | // Now click to select this node. 118 | firstNode.click(); 119 | // Make sure the details pane should be showing something. 120 | var details = element(by.id('details')); 121 | expect(details).toBeDefined(); 122 | expect(details.isDisplayed()).toBe(true); 123 | // Also make sure the details are populated from real data. 124 | expect(element(by.repeater('(tag, value) in getSelectionDetails()'))).toBeDefined(); 125 | 126 | // Now ensure we can navigate to the node inspection page. 127 | var inspectBtn = element(by.id('inspectBtn')); 128 | expect(inspectBtn).toBeDefined(); 129 | inspectBtn.click(); 130 | // Check if we arrive at the inspection page. 131 | expect(browser.getLocationAbsUrl()).toBe('/graph/inspect'); 132 | 133 | // Ensure the inspection page has the details populated. 134 | expect(element(by.repeater('(key, val) in json track by $index'))).toBeDefined(); 135 | 136 | // Ensure the inspection page has a back button and it works. 137 | var backBtn = element(by.id('backButton')); 138 | expect(backBtn).toBeDefined(); 139 | backBtn.click(); 140 | expect(browser.getLocationAbsUrl()).toBe('/graph/'); 141 | }); 142 | 143 | }); 144 | -------------------------------------------------------------------------------- /js/modules/services/d3UtilitiesService.js: -------------------------------------------------------------------------------- 1 | /** 2 | Copyright 2015 Google Inc. All rights reserved. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | /**========================================================= 18 | * Module: Graph 19 | * Visualizer for force directed graph. 20 | * This is a service that provides stateless utility 21 | * functions for use by the d3 visualization directive. 22 | =========================================================*/ 23 | 24 | (function() { 25 | 'use strict'; 26 | 27 | var d3UtilitiesService = function() { 28 | // Return a random position [x,y] within radius of the origin. 29 | function getRandomStartingPosition(radius) { 30 | var t = 2 * Math.PI * Math.random(); 31 | var u = Math.random() + Math.random(); 32 | var r = u > 1 ? 2 - u : u; 33 | 34 | return [r * Math.cos(t) * radius, r * Math.sin(t) * radius]; 35 | } 36 | 37 | // This function looks up whether a pair of nodes are neighbours. 38 | function neighboring(a, b, linkedByIndex, selectionHops) { 39 | // TODO(duftler): Add support for > 1 hops. 40 | if (selectionHops) { 41 | return linkedByIndex[a.index + ',' + b.index]; 42 | } else { 43 | return false; 44 | } 45 | } 46 | 47 | // Match on Set.has() or id. 48 | function setHas(searchSet, item) { 49 | if (searchSet.has(item)) { 50 | return true; 51 | } 52 | 53 | var found = false; 54 | 55 | searchSet.forEach(function(e) { 56 | if (e.id !== undefined && e.id === item.id) { 57 | found = true; 58 | return; 59 | } 60 | }); 61 | 62 | return found; 63 | } 64 | 65 | // Returns an object containing: 66 | // clusters: An array where each index is a cluster number and the value stored at that index is the node with 67 | // the maximum radius in that cluster. 68 | // maxRadius: The maximum radius of all the nodes. 69 | // 70 | function buildClusters(nodes) { 71 | var maxRadius = -1; 72 | var maxCluster = -1; 73 | 74 | nodes.forEach(function(d) { 75 | maxCluster = Math.max(maxCluster, d.cluster); 76 | maxRadius = Math.max(maxRadius, d.radius); 77 | }); 78 | 79 | var clusters = new Array(maxCluster + 1); 80 | 81 | nodes.forEach(function(d) { 82 | if (!clusters[d.cluster] || (d.radius > clusters[d.cluster].radius)) { 83 | clusters[d.cluster] = d; 84 | } 85 | }); 86 | 87 | return {clusters: clusters, maxRadius: maxRadius}; 88 | } 89 | 90 | // Move d to be adjacent to the cluster node. 91 | function cluster(builtClusters, alpha) { 92 | return function(d) { 93 | var cluster = builtClusters.clusters[d.cluster]; 94 | if (cluster === d) return; 95 | if (d.x == cluster.x && d.y == cluster.y) { 96 | d.x += 0.1; 97 | } 98 | var x = d.x - cluster.x, y = d.y - cluster.y, l = Math.sqrt(x * x + y * y), r = d.radius + cluster.radius; 99 | if (l != r) { 100 | l = (l - r) / l * alpha; 101 | d.x -= x *= l; 102 | d.y -= y *= l; 103 | cluster.x += x; 104 | cluster.y += y; 105 | } 106 | }; 107 | } 108 | 109 | // Resolves collisions between d and all other nodes. 110 | function collide(d3, nodes, builtClusters, alpha, clusterInnerPadding, clusterOuterPadding) { 111 | var quadtree = d3.geom.quadtree(nodes); 112 | return function(d) { 113 | var r = d.radius + builtClusters.maxRadius + Math.max(clusterInnerPadding, clusterOuterPadding), nx1 = d.x - r, 114 | nx2 = d.x + r, ny1 = d.y - r, ny2 = d.y + r; 115 | quadtree.visit(function(quad, x1, y1, x2, y2) { 116 | if (quad.point && (quad.point !== d)) { 117 | var x = d.x - quad.point.x, y = d.y - quad.point.y, l = Math.sqrt(x * x + y * y), 118 | r = d.radius + quad.point.radius + 119 | (d.cluster === quad.point.cluster ? clusterInnerPadding : clusterOuterPadding); 120 | if (l < r) { 121 | l = (l - r) / l * alpha; 122 | d.x -= x *= l; 123 | d.y -= y *= l; 124 | quad.point.x += x; 125 | quad.point.y += y; 126 | } 127 | } 128 | return x1 > nx2 || x2 < nx1 || y1 > ny2 || y2 < ny1; 129 | }); 130 | }; 131 | } 132 | 133 | function showContextMenu(d3, data, index, contextMenu) { 134 | var elm = this; 135 | 136 | d3.selectAll('.d3-context-menu').html(''); 137 | var list = d3.selectAll('.d3-context-menu').append('ul'); 138 | list.selectAll('li') 139 | .data(contextMenu) 140 | .enter() 141 | .append('li') 142 | .html(function(d) { return (typeof d.title === 'string') ? d.title : d.title(data); }) 143 | .on('click', function(d, i) { 144 | d.action(elm, data, index); 145 | d3.select('.d3-context-menu').style('display', 'none'); 146 | }); 147 | 148 | // Display context menu. 149 | d3.select('.d3-context-menu') 150 | .style('left', (d3.event.pageX - 2) + 'px') 151 | .style('top', (d3.event.pageY - 2) + 'px') 152 | .style('display', 'block') 153 | .on('contextmenu', function() { d3.event.preventDefault(); }); 154 | 155 | d3.event.preventDefault(); 156 | } 157 | 158 | return { 159 | 'getRandomStartingPosition': getRandomStartingPosition, 160 | 'neighboring': neighboring, 161 | 'setHas': setHas, 162 | 'buildClusters': buildClusters, 163 | 'cluster': cluster, 164 | 'collide': collide, 165 | 'showContextMenu': showContextMenu 166 | }; 167 | }; 168 | 169 | angular.module('kubernetesApp.components.graph.services.d3', []).service('d3UtilitiesService', [d3UtilitiesService]); 170 | 171 | })(); 172 | -------------------------------------------------------------------------------- /assets/transforms/templateTransform.js: -------------------------------------------------------------------------------- 1 | /** 2 | Copyright 2015 Google Inc. All rights reserved. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | function templateTransform(_, template) { 17 | var stringifyNoQuotes = function(result) { 18 | if (typeof result !== "string") { 19 | if (result !== "undefined") { 20 | result = JSON.stringify(result); 21 | result = result.replace(/\"([^(\")"]+)\":/g, "$1:"); 22 | } else { 23 | result = "undefined"; 24 | } 25 | } 26 | 27 | return result; 28 | }; 29 | 30 | var evalExpression = function(item, expression) { 31 | var result = undefined; 32 | var expr = expression.eval; 33 | if (typeof expr === "string") { 34 | var args = []; 35 | if (_.isArray(expression.args)) { 36 | _.forEach(expression.args, function(results) { 37 | if (typeof results === "string") { 38 | if (results.charAt(0) === "$") { 39 | results = JSONPath(null, item, results); 40 | } 41 | if (results && results.length > 0) { 42 | results = _.map(results, function(result) { return stringifyNoQuotes(result); }); 43 | args.push(results); 44 | } 45 | } 46 | }); 47 | 48 | expr = vsprintf(expr, args); 49 | } 50 | 51 | result = eval(expr); 52 | } 53 | 54 | return result; 55 | }; 56 | 57 | var filterItem = function(filters) { 58 | return function(fromItem) { 59 | return _.every(filters, function(filter) { return evalExpression(fromItem, filter); }); 60 | }; 61 | }; 62 | 63 | var mapItem = function(fromItem, toItem, maps) { 64 | var random = function() { 65 | var str = JSON.stringify(Math.random()); 66 | var idx = str.indexOf(".") + 1; 67 | if (idx > 0 && idx < str.length) { 68 | str = str.substring(idx); 69 | } 70 | 71 | return str; 72 | }; 73 | 74 | var mapProperties = function(fromItem, toItem, properties) { 75 | var evalMapping = function(item, mapping) { 76 | var result = undefined; 77 | if (mapping) { 78 | if (typeof mapping === "string") { 79 | if (mapping.charAt(0) === "$") { 80 | results = JSONPath(null, item, mapping); 81 | if (results && results.length > 0) { 82 | if (results.length === 1) { 83 | result = results[0]; 84 | } 85 | } 86 | } else { 87 | result = mapping; 88 | } 89 | } else if (typeof mapping === "object") { 90 | if (mapping.expression) { 91 | result = evalExpression(item, mapping); 92 | } else { 93 | result = mapProperties(item, {}, mapping); 94 | } 95 | } else if (_.isArray(mapping)) { 96 | result = _.map(mapping, function(member) { return evalMapping(item, member); }); 97 | } 98 | } 99 | 100 | return result; 101 | }; 102 | 103 | if (properties) { 104 | _.forOwn(properties, function(mapping, property) { 105 | mapping = evalMapping(fromItem, mapping); 106 | if (mapping) { 107 | property = evalMapping(fromItem, property); 108 | if (property) { 109 | property = stringifyNoQuotes(property); 110 | toItem[property] = mapping; 111 | } 112 | } 113 | }); 114 | } 115 | 116 | return toItem; 117 | }; 118 | 119 | toItem.id = fromItem.id || random(); 120 | if (maps) { 121 | // TODO: Apply maps progressively not sequentially. 122 | _.forEach(maps, function(map) { 123 | if (!map.filter || evalExpression(fromItem, map.filter)) { 124 | mapProperties(fromItem, toItem, map.properties); 125 | } 126 | }); 127 | } 128 | }; 129 | 130 | var mapNodes = function(fromNodes, toData) { 131 | var mapNode = function(fromNode) { 132 | var toNode = {}; 133 | mapItem(fromNode, toNode, template.nodeMaps); 134 | return toNode; 135 | }; 136 | 137 | var sortNode = function(fromNode) { return fromNode.id; }; 138 | 139 | var chain = _.chain(fromNodes); 140 | if (template.nodeFilters) { 141 | chain = chain.filter(filterItem(template.nodeFilters)); 142 | } 143 | 144 | toData.nodes = chain.map(mapNode).sortBy(sortNode).value(); 145 | }; 146 | 147 | var mapLinks = function(fromLinks, toData) { 148 | var mapLink = function(fromLink) { 149 | var toLink = {}; 150 | mapItem(fromLink, toLink, template.edgeMaps); 151 | return toLink; 152 | }; 153 | 154 | var sortLink = function(fromLink) { return fromLink.source + ":" + fromLink.target; }; 155 | 156 | var chain = _.chain(fromLinks); 157 | if (template.edgeFilters) { 158 | chain = chain.filter(filterItem(template.edgeFilters)); 159 | } 160 | 161 | toData.links = chain.map(mapLink).sortBy(sortLink).value(); 162 | }; 163 | 164 | return function(fromData, configuration) { 165 | var toData = {}; 166 | toData.configuration = configuration; 167 | if (template.configuration) { 168 | if (template.configuration.legend) { 169 | toData.configuration.legend = template.configuration.legend; 170 | } 171 | 172 | if (template.configuration.settings) { 173 | toData.configuration.settings = template.configuration.settings; 174 | } 175 | 176 | if (template.configuration.selectionHops) { 177 | toData.configuration.selectionHops = template.configuration.selectionHops; 178 | } 179 | 180 | if (template.configuration.selectionIdList) { 181 | toData.configuration.selectionIdList = template.configuration.selectionIdList; 182 | } 183 | } 184 | 185 | if (fromData) { 186 | if (fromData.resources) { 187 | mapNodes(fromData.resources, toData); 188 | if (fromData.relations) { 189 | mapLinks(fromData.relations, toData); 190 | } 191 | } 192 | } 193 | 194 | return toData; 195 | }; 196 | } 197 | -------------------------------------------------------------------------------- /pages/home.html: -------------------------------------------------------------------------------- 1 | 16 |
17 |
18 | 19 |
20 | 21 | 39 | 40 | 41 | 42 | 43 | 44 | Toggle Live Data 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | Poll Continuosly 53 | 54 | 55 | 56 | 57 | 58 | 59 | 60 | Poll Once 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | Inspect Selected Nodes 70 | 71 | 72 |
73 |
74 | 75 |
76 |
77 | 78 | 79 | 80 | Legend 81 | 82 | Toggle Legend 83 | 84 | 85 | 86 |
87 | 88 | 89 | 90 | 91 | 92 | 111 | 114 | 115 | 116 |
93 | 94 | 99 | 100 | 101 | 103 | 105 | 106 | 107 | 108 | Show/Hide {{getLegendNodeDisplayName(type)}} 109 | 110 | 112 | {{getLegendNodeDisplayName(type)}} 113 |
117 |
118 | 119 | 120 | 121 | 122 | 123 | 133 | 136 | 137 | 138 |
124 | 125 | 130 | 131 | 132 | 134 | {{type}} 135 |
139 |
140 |
141 |
142 | 143 | 144 | 145 | Details 146 | 147 | Show/Hide Selection Details 148 | 149 | 150 | 151 |
152 | 153 | 154 | 155 | 156 | 157 | 160 | 163 | 164 | 165 |
158 | 159 | 161 |
{{value}}
162 |
166 |
167 |
168 |
169 |
170 |
171 |
172 | 173 | 174 | 175 |
176 |
177 |
178 |
179 | -------------------------------------------------------------------------------- /js/modules/controllers/graph.js: -------------------------------------------------------------------------------- 1 | /** 2 | Copyright 2015 Google Inc. All rights reserved. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | /**========================================================= 18 | * Module: Graph 19 | * Visualizer for force directed graph 20 | =========================================================*/ 21 | (function() { 22 | "use strict"; 23 | angular.module("kubernetesApp.components.graph", [ 24 | "kubernetesApp.services", 25 | "kubernetesApp.components.graph.services", 26 | "kubernetesApp.components.graph.services.d3", 27 | "kubernetesApp.components.graph.services.d3.rendering", 28 | "yaru22.jsonHuman" 29 | ]) 30 | .controller("GraphCtrl", [ 31 | "$scope", 32 | "lodash", 33 | "viewModelService", 34 | "pollK8sDataService", 35 | "$location", 36 | "$window", 37 | "inspectNodeService", 38 | function($scope, _, viewModelService, pollK8sDataService, $location, $window, inspectNodeService) { 39 | $scope.showHide = function(id) { 40 | var element = document.getElementById(id); 41 | if (element) { 42 | element.style.display = (element.style.display === "none") ? "block" : "none"; 43 | } 44 | }; 45 | 46 | $scope.showElement = function(id) { 47 | var element = document.getElementById(id); 48 | if (element) { 49 | element.style.display = "block"; 50 | } 51 | }; 52 | 53 | $scope.hideElement = function(id) { 54 | var element = document.getElementById(id); 55 | if (element) { 56 | element.style.display = "none"; 57 | } 58 | }; 59 | 60 | $scope.pollK8sDataService = pollK8sDataService; 61 | 62 | $scope.getPlayIcon = function() { 63 | return pollK8sDataService.isPolling() ? "components/graph/img/Pause.svg" : "components/graph/img/Play.svg"; 64 | }; 65 | 66 | $scope.togglePlay = function() { 67 | if (pollK8sDataService.isPolling()) { 68 | pollK8sDataService.stop($scope); 69 | } else { 70 | pollK8sDataService.start($scope); 71 | } 72 | }; 73 | 74 | // Update the view when the polling starts or stops. 75 | $scope.$watch("pollK8sDataService.isPolling()", function(newValue, oldValue) { 76 | if (newValue !== oldValue) { 77 | $scope.$apply(); 78 | } 79 | }); 80 | 81 | $scope.getSourceIcon = function() { 82 | return pollK8sDataService.k8sdatamodel.useSampleData ? "components/graph/img/SampleData.svg" : 83 | "components/graph/img/LiveData.svg"; 84 | }; 85 | 86 | $scope.toggleSource = function() { 87 | pollK8sDataService.k8sdatamodel.useSampleData = !pollK8sDataService.k8sdatamodel.useSampleData; 88 | pollK8sDataService.refresh($scope); 89 | }; 90 | 91 | $scope.refresh = function() { pollK8sDataService.refresh($scope); }; 92 | 93 | $scope.viewModelService = viewModelService; 94 | $scope.getTransformNames = function() { return _.sortBy(viewModelService.viewModel.transformNames); }; 95 | 96 | $scope.selectedTransformName = viewModelService.defaultTransformName; 97 | 98 | // Sets the selected transform name 99 | $scope.setSelectedTransformName = function(transformName) { 100 | pollK8sDataService.stop($scope); 101 | $scope.selectedTransformName = transformName; 102 | $scope.updateModel(); 103 | }; 104 | 105 | $scope.updateModel = function() { 106 | viewModelService.generateViewModel(pollK8sDataService.k8sdatamodel.data, $scope.selectedTransformName); 107 | }; 108 | 109 | // Update the view model when the data model changes. 110 | $scope.$watch("pollK8sDataService.k8sdatamodel.sequenceNumber", function(newValue, oldValue) { 111 | if (newValue !== oldValue) { 112 | $scope.updateModel(); 113 | } 114 | }); 115 | 116 | pollK8sDataService.refresh($scope); 117 | 118 | $scope.getLegendNodeTypes = function() { 119 | var result = []; 120 | var legend = viewModelService.getLegend(); 121 | if (legend && legend.nodes) { 122 | result = _.keys(legend.nodes) 123 | .filter(function(type) { return legend.nodes[type].available; }) 124 | .sort(); 125 | } 126 | 127 | return result; 128 | }; 129 | 130 | $scope.getLegendNodeDisplayName = function(type) { 131 | var result = type; 132 | var legend = viewModelService.getLegend(); 133 | if (legend && legend.nodes && legend.nodes[type] && legend.nodes[type].displayName) { 134 | result = legend.nodes[type].displayName; 135 | } 136 | 137 | return result; 138 | }; 139 | 140 | $scope.getLegendNodeFill = function(type) { 141 | var result = "white"; 142 | var legend = viewModelService.getLegend(); 143 | if (legend && legend.nodes && legend.nodes[type]) { 144 | if (legend.nodes[type].selected) { 145 | if (legend.nodes[type].style) { 146 | result = legend.nodes[type].style.fill; 147 | } 148 | } 149 | } 150 | 151 | return result; 152 | }; 153 | 154 | $scope.getLegendNodeStroke = function(type) { 155 | var result = "dimgray"; 156 | var legend = viewModelService.getLegend(); 157 | if (legend && legend.nodes && legend.nodes[type]) { 158 | if (legend.nodes[type].style.stroke) { 159 | result = legend.nodes[type].style.stroke; 160 | } 161 | } 162 | 163 | return result; 164 | }; 165 | 166 | $scope.getLegendNodeStrokeWidth = function(type) { 167 | var result = "1"; 168 | var legend = viewModelService.getLegend(); 169 | if (legend && legend.nodes && legend.nodes[type]) { 170 | if (legend.nodes[type].style.strokeWidth) { 171 | result = legend.nodes[type].style.strokeWidth; 172 | } 173 | } 174 | 175 | return result; 176 | }; 177 | 178 | $scope.getLegendNodeIcon = function(type) { 179 | var result = null; 180 | var legend = viewModelService.getLegend(); 181 | if (legend && legend.nodes && legend.nodes[type]) { 182 | if (legend.nodes[type].style.icon) { 183 | result = legend.nodes[type].style.icon; 184 | } 185 | } 186 | 187 | return result; 188 | }; 189 | 190 | $scope.getLegendLinkTypes = function() { 191 | var result = []; 192 | var legend = viewModelService.getLegend(); 193 | if (legend && legend.links) { 194 | result = _.keys(legend.links).filter(function(type) { return legend.links[type].available; }).sort(); 195 | } 196 | 197 | return result; 198 | }; 199 | 200 | $scope.getLegendLinkStyle = function(type) { 201 | var result = {}; 202 | var legend = viewModelService.getLegend(); 203 | if (legend && legend.links) { 204 | result = legend.links[type].style; 205 | } 206 | 207 | return result; 208 | }; 209 | 210 | $scope.getLegendLinkStyleStrokeWidth = function(type, defaultWidth) { 211 | var style = $scope.getLegendLinkStyle(type); 212 | return $window.Math.max(style.width, defaultWidth); 213 | }; 214 | 215 | $scope.toggleLegend = function(type) { 216 | if (type) { 217 | var legend = viewModelService.getLegend(); 218 | if (legend.nodes) { 219 | legend.nodes[type].selected = !legend.nodes[type].selected; 220 | $scope.updateModel(); 221 | } 222 | } 223 | }; 224 | 225 | var getSelection = function() { 226 | var selectedNode = undefined; 227 | var selectionIdList = viewModelService.getSelectionIdList(); 228 | if (selectionIdList && selectionIdList.length > 0) { 229 | var selectedId = selectionIdList[0]; 230 | selectedNode = 231 | _.find(viewModelService.viewModel.data.nodes, function(node) { return node.id === selectedId; }); 232 | } 233 | 234 | return selectedNode; 235 | }; 236 | 237 | var stringifyNoQuotes = function(result) { 238 | if (typeof result !== "string") { 239 | if (result !== "undefined") { 240 | result = JSON.stringify(result, null, 2); 241 | result = result.replace(/\"([^(\")"]+)\":/g, "$1:"); 242 | } else { 243 | result = "undefined"; 244 | } 245 | } 246 | 247 | return result; 248 | }; 249 | 250 | $scope.getSelectionDetails = function() { 251 | var results = {}; 252 | var selectedNode = getSelection(); 253 | if (selectedNode && selectedNode.tags) { 254 | _.forOwn(selectedNode.tags, function(value, property) { 255 | if (value) { 256 | var result = stringifyNoQuotes(value); 257 | if (result.length > 0) { 258 | results[property] = result.trim(); 259 | } 260 | } 261 | }); 262 | } 263 | 264 | return results; 265 | }; 266 | 267 | $scope.inspectSelection = function() { 268 | var selectedNode = getSelection(); 269 | if (selectedNode && selectedNode.metadata) { 270 | inspectNodeService.setDetailData(selectedNode); 271 | $location.path('/graph/inspect'); 272 | } 273 | }; 274 | 275 | $scope.$watch("viewModelService.getSelectionIdList()", function(newValue, oldValue) { 276 | if (newValue !== oldValue) { 277 | var selectionIdList = viewModelService.getSelectionIdList(); 278 | if (!selectionIdList || selectionIdList.length < 1) { 279 | $scope.hideElement("details"); 280 | } else { 281 | $scope.showElement("details"); 282 | } 283 | } 284 | }); 285 | 286 | $scope.getExpandIcon = function() { 287 | return viewModelService.getSettings().clustered ? "components/graph/img/Collapse.svg" : 288 | "components/graph/img/Expand.svg"; 289 | }; 290 | 291 | $scope.toggleExpand = function() { 292 | var settings = viewModelService.getSettings(); 293 | settings.clustered = !settings.clustered; 294 | $scope.updateModel(); 295 | }; 296 | 297 | $scope.getSelectIcon = function() { 298 | return viewModelService.getSelectionHops() ? "components/graph/img/SelectMany.svg" : 299 | "components/graph/img/SelectOne.svg"; 300 | }; 301 | 302 | $scope.toggleSelect = function() { 303 | var selectionHops = viewModelService.getSelectionHops(); 304 | if (!selectionHops) { 305 | viewModelService.setSelectionHops(1); 306 | } else { 307 | viewModelService.setSelectionHops(0); 308 | } 309 | }; 310 | } 311 | ]); 312 | }()); 313 | -------------------------------------------------------------------------------- /js/modules/services/viewModelService.js: -------------------------------------------------------------------------------- 1 | /** 2 | Copyright 2015 Google Inc. All rights reserved. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | /**========================================================= 17 | * Module: Graph 18 | * Visualizer for force directed graph 19 | =========================================================*/ 20 | (function() { 21 | "use strict"; 22 | 23 | // Compute the view model based on the data model and control parameters 24 | // and place the result in the current scope at $scope.viewModel. 25 | var viewModelService = function ViewModelService(_) { 26 | var defaultConfiguration = { 27 | "legend": undefined, 28 | "settings": { 29 | "clustered": false, 30 | "showEdgeLabels": false, 31 | "showNodeLabels": true 32 | }, 33 | "selectionHops": 1, 34 | "selectionIdList": [] 35 | }; 36 | 37 | var defaultNode = { 38 | name: 'no data' 39 | }; 40 | 41 | var defaultData = { 42 | "configuration": defaultConfiguration, 43 | "nodes": [], 44 | "links": [] 45 | }; 46 | 47 | // Load the default data. 48 | var loadDefaultData = function(defaultLegend) { 49 | if (defaultLegend && defaultLegend.nodes && defaultLegend.nodes.Cluster) { 50 | var legendEntry = defaultLegend.nodes.Cluster; 51 | if (legendEntry) { 52 | var legendStyle = legendEntry.style; 53 | if (legendStyle) { 54 | _.assign(defaultNode, legendStyle); 55 | } 56 | } 57 | } 58 | 59 | if (defaultData.nodes.length < 1) { 60 | defaultData.nodes = [defaultNode]; 61 | } 62 | }; 63 | 64 | // Load the default legend. 65 | (function() { 66 | $.getJSON("components/graph/assets/legend.json") 67 | .done(function(legend) { 68 | defaultData.configuration.legend = legend; 69 | loadDefaultData(legend); 70 | }) 71 | .fail(function(jqxhr, settings, exception) { 72 | console.log('ERROR: Could not load default legend: ' + exception); 73 | }); 74 | }()); 75 | 76 | var viewModel = { 77 | "data": undefined, 78 | "default": undefined, 79 | "version": 0, 80 | "transformNames": [] 81 | }; 82 | 83 | var getViewModelData = function() { 84 | if (!viewModel.data || viewModel.data.nodes.length < 1) { 85 | viewModel.data = defaultData; 86 | } 87 | 88 | if (!viewModel.default || viewModel.default.nodes.length < 1) { 89 | viewModel.default = defaultData; 90 | } 91 | 92 | return viewModel.data; 93 | }; 94 | 95 | var getViewModelConfiguration = function() { 96 | var data = getViewModelData(); 97 | return data ? data.configuration : undefined; 98 | }; 99 | 100 | var getLegend = function() { 101 | return (getViewModelConfiguration()) ? 102 | viewModel.data.configuration.legend : 103 | undefined; 104 | }; 105 | 106 | var setLegend = function(legend) { 107 | if (getViewModelConfiguration()) { 108 | viewModel.data.configuration.legend = legend; 109 | } 110 | }; 111 | 112 | var getSettings = function() { 113 | return (getViewModelConfiguration()) ? 114 | viewModel.data.configuration.settings : 115 | undefined; 116 | }; 117 | 118 | var setSettings = function(settings) { 119 | if (getViewModelConfiguration()) { 120 | viewModel.data.configuration.settings = settings; 121 | } 122 | }; 123 | 124 | var getSelectionHops = function() { 125 | return (getViewModelConfiguration()) ? 126 | viewModel.data.configuration.selectionHops : 127 | 1; 128 | }; 129 | 130 | var setSelectionHops = function(selectionHops) { 131 | if (getViewModelConfiguration()) { 132 | viewModel.data.configuration.selectionHops = selectionHops; 133 | } 134 | }; 135 | 136 | var getSelectionIdList = function() { 137 | return (getViewModelConfiguration()) ? 138 | viewModel.data.configuration.selectionIdList : []; 139 | }; 140 | 141 | var setSelectionIdList = function(selectionIdList) { 142 | if (getViewModelConfiguration()) { 143 | viewModel.data.configuration.selectionIdList = selectionIdList; 144 | } 145 | }; 146 | 147 | var defaultTransformName = undefined; 148 | var transformsByName = {}; 149 | 150 | // Load transforms. 151 | (function() { 152 | var stripSuffix = function(fileName) { 153 | var suffixIndex = fileName.indexOf("."); 154 | if (suffixIndex > 0) { 155 | fileName = fileName.substring(0, suffixIndex); 156 | } 157 | 158 | return fileName; 159 | }; 160 | 161 | var getConstructor = function(constructorName) { 162 | return window[constructorName]; 163 | }; 164 | 165 | var bindTransform = function(constructorName, directoryEntry) { 166 | var constructor = getConstructor(constructorName); 167 | if (constructor) { 168 | var transform = constructor(_, directoryEntry.data); 169 | if (transform) { 170 | if (!defaultTransformName) { 171 | defaultTransformName = directoryEntry.name; 172 | } 173 | 174 | viewModel.transformNames.push(directoryEntry.name); 175 | transformsByName[directoryEntry.name] = transform; 176 | return; 177 | } 178 | } 179 | 180 | console.log('ERROR: Could not bind transform "' + directoryEntry.name + '".'); 181 | }; 182 | 183 | // Load a transform from a given directory entry. 184 | var loadTransform = function(directoryEntry) { 185 | if (directoryEntry && directoryEntry.name && directoryEntry.script) { 186 | var constructorName = stripSuffix(directoryEntry.script); 187 | if (!getConstructor(constructorName)) { 188 | // Load the script into the window scope. 189 | var scriptPath = "components/graph/assets/transforms/" + directoryEntry.script; 190 | $.getScript(scriptPath) 191 | .done(function() { 192 | // Defer to give the load opportunity to complete. 193 | _.defer(function() { 194 | bindTransform(constructorName, directoryEntry); 195 | }); 196 | }) 197 | .fail(function(jqxhr, settings, exception) { 198 | console.log('ERROR: Could not load transform "' + directoryEntry.name + '": ' + exception); 199 | }); 200 | } else { 201 | bindTransform(constructorName, directoryEntry); 202 | } 203 | } 204 | }; 205 | 206 | // Load the transform directory 207 | $.getJSON("components/graph/assets/transforms.json") 208 | .done(function(transforms) { 209 | // Defer to give the load opportunity to complete. 210 | _.defer(function() { 211 | if (transforms.directory) { 212 | _.forEach(transforms.directory, function(directoryEntry) { 213 | loadTransform(directoryEntry); 214 | }); 215 | } 216 | }); 217 | }) 218 | .fail(function(jqxhr, settings, exception) { 219 | console.log('ERROR: Could not load transform directory: ' + exception); 220 | }); 221 | }()); 222 | 223 | var setViewModel = function(data) { 224 | if (data && data.nodes && data.configuration && data.configuration.settings) { 225 | viewModel.data = data; 226 | viewModel.version++; 227 | } 228 | }; 229 | 230 | // Generate the view model from a given data model using a given transform. 231 | var generateViewModel = function(fromData, transformName) { 232 | var initializeConfiguration = function(toData) { 233 | var initializeLegend = function(fromConfiguration, toConfiguration) { 234 | var toLegend = toConfiguration.legend; 235 | var fromLegend = fromConfiguration.legend; 236 | if (!toLegend) { 237 | toConfiguration.legend = JSON.parse(JSON.stringify(fromLegend)); 238 | } else { 239 | if (!toLegend.nodes) { 240 | toLegend.nodes = JSON.parse(JSON.stringify(fromLegend.nodes)); 241 | } 242 | 243 | if (!toLegend.links) { 244 | toLegend.links = JSON.parse(JSON.stringify(fromLegend.links)); 245 | } 246 | } 247 | }; 248 | 249 | var initializeSettings = function(fromConfiguration, toConfiguration) { 250 | if (!toConfiguration.settings) { 251 | toConfiguration.settings = JSON.parse(JSON.stringify(fromConfiguration.settings)); 252 | } 253 | }; 254 | 255 | var initializeSelection = function(fromConfiguration, toConfiguration) { 256 | if (!toConfiguration.selectionHops) { 257 | toConfiguration.selectionHops = fromConfiguration.selectionHops; 258 | } 259 | 260 | if (!toConfiguration.selectionIdList) { 261 | toConfiguration.selectionIdList = fromConfiguration.selectionIdList; 262 | } 263 | }; 264 | 265 | var toConfiguration = toData.configuration; 266 | var fromConfiguration = viewModel.data.configuration; 267 | 268 | if (!toConfiguration) { 269 | toData.configuration = JSON.parse(JSON.stringify(fromConfiguration)); 270 | } else { 271 | initializeLegend(fromConfiguration, toConfiguration); 272 | initializeSettings(fromConfiguration, toConfiguration); 273 | initializeSelection(fromConfiguration, toConfiguration); 274 | } 275 | }; 276 | 277 | var processNodes = function(toData) { 278 | var typeToCluster = {}; 279 | var idToIndex = {}; 280 | 281 | var setIndex = function(toNode, idToIndex) { 282 | if (!idToIndex[toNode.id]) { 283 | idToIndex[toNode.id] = _.keys(idToIndex).length; 284 | } 285 | }; 286 | 287 | var setCluster = function(toNode, typeToCluster) { 288 | if (toNode.type) { 289 | toNode.cluster = typeToCluster[toNode.type]; 290 | if (toNode.cluster === undefined) { 291 | toNode.cluster = _.keys(typeToCluster).length; 292 | typeToCluster[toNode.type] = toNode.cluster; 293 | } 294 | } else { 295 | toNode.cluster = 0; 296 | } 297 | }; 298 | 299 | var setStyle = function(toItem, entries) { 300 | if (toItem.type && entries[toItem.type]) { 301 | _.assign(toItem, entries[toItem.type].style); 302 | entries[toItem.type].available = true; 303 | } else { 304 | toItem.type = undefined; 305 | } 306 | }; 307 | 308 | var processLinks = function(toData, legend, filtered) { 309 | var getIndex = function(toLink, idToIndex) { 310 | if (toLink.source && toLink.target) { 311 | toLink.source = idToIndex[toLink.source]; 312 | toLink.target = idToIndex[toLink.target]; 313 | } 314 | }; 315 | 316 | var chain = _.chain(toData.links) 317 | .forEach(function(toLink) { 318 | setStyle(toLink, legend.links); 319 | if (toLink.type) { 320 | getIndex(toLink, idToIndex); 321 | } 322 | }); 323 | 324 | chain = chain.filter("type"); 325 | if (filtered) { 326 | chain = chain.filter(function(toLink) { 327 | return (toLink.source !== undefined) && (toLink.target !== undefined); 328 | }); 329 | } 330 | 331 | toData.links = chain.value(); 332 | }; 333 | 334 | var configuration = toData.configuration; 335 | var legend = configuration.legend; 336 | var settings = configuration.settings; 337 | 338 | _.forOwn(legend.nodes, function(nodeEntry) { 339 | nodeEntry.available = false; 340 | }); 341 | 342 | var chain = _.chain(toData.nodes).forEach(function(toNode) { 343 | setStyle(toNode, legend.nodes); 344 | }); 345 | 346 | var filtered = _.any(legend.nodes, function(nodeEntry) { 347 | return !nodeEntry.selected; 348 | }); 349 | 350 | chain = chain.filter("type"); 351 | if (filtered) { 352 | chain = chain.filter(function(toNode) { 353 | return legend.nodes[toNode.type] ? legend.nodes[toNode.type].selected : false; 354 | }); 355 | } 356 | 357 | if (settings && settings.clustered) { 358 | chain = chain.forEach(function(toNode) { 359 | setCluster(toNode, typeToCluster); 360 | }); 361 | } 362 | 363 | toData.nodes = chain.forEach(function(toNode) { 364 | setIndex(toNode, idToIndex); 365 | }).value(); 366 | 367 | if (toData.links) { 368 | processLinks(toData, legend, filtered); 369 | } 370 | }; 371 | 372 | if (fromData && transformName) { 373 | var transform = transformsByName[transformName]; 374 | if (transform) { 375 | var configuration = JSON.parse(JSON.stringify(viewModel.data.configuration)); 376 | var toData = transform(fromData, configuration); 377 | if (toData.nodes) { 378 | initializeConfiguration(toData); 379 | processNodes(toData); 380 | } else { 381 | toData.configuration = configuration; 382 | toData.nodes = defaultData.nodes; 383 | toData.links = defaultData.links; 384 | } 385 | 386 | setViewModel(toData); 387 | } else { 388 | console.log('ERROR: Could not find transform "' + transformName + '".'); 389 | } 390 | } 391 | }; 392 | 393 | this.$get = function() { 394 | return { 395 | "viewModel": viewModel, 396 | "getLegend": getLegend, 397 | "setLegend": setLegend, 398 | "getSettings": getSettings, 399 | "setSettings": setSettings, 400 | "getSelectionIdList": getSelectionIdList, 401 | "setSelectionIdList": setSelectionIdList, 402 | "getSelectionHops": getSelectionHops, 403 | "setSelectionHops": setSelectionHops, 404 | "defaultTransformName": defaultTransformName, 405 | "generateViewModel": generateViewModel, 406 | "setViewModel": setViewModel 407 | }; 408 | }; 409 | }; 410 | 411 | angular.module("kubernetesApp.components.graph").provider("viewModelService", ["lodash", viewModelService]); 412 | }()); -------------------------------------------------------------------------------- /test/modules/services/d3RenderingService.spec.js: -------------------------------------------------------------------------------- 1 | /** 2 | Copyright 2015 Google Inc. All rights reserved. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | 17 | 'use strict'; 18 | 19 | // TODO(duftler): 20 | // Add tests for: 21 | // clustered view 22 | 23 | describe('D3 rendering service', function() { 24 | var d3RenderingService; 25 | var d3UtilitiesService; 26 | var parentDiv; 27 | var graphDiv; 28 | var scope; 29 | var d3Rendering; 30 | 31 | var MOCK_SAMPLE_DATA = [ 32 | { 33 | 'nodes': [ 34 | {'name': 'service: guestbook', 'radius': 16, 'fill': 'olivedrab', 'id': 1, 'selected': true}, 35 | {'name': 'pod: guestbook-controller', 'radius': 20, 'fill': 'palegoldenrod', 'id': 2, 'selected': true}, 36 | {'name': 'pod: guestbook-controller', 'radius': 20, 'fill': 'palegoldenrod', 'id': 3, 'selected': true}, 37 | {'name': 'pod: guestbook-controller', 'radius': 20, 'fill': 'palegoldenrod', 'id': 55}, 38 | {'name': 'container: php-redis', 'radius': 24, 'fill': 'cornflowerblue', 'id': 77} 39 | ], 40 | 'links': [ 41 | {'source': 0, 'target': 1, 'width': 2, 'stroke': 'black', 'distance': 80}, 42 | {'source': 0, 'target': 2, 'width': 2, 'stroke': 'black', 'distance': 80}, 43 | {'source': 1, 'target': 3, 'width': 2, 'stroke': 'black', 'distance': 80} 44 | ], 45 | 'configuration': {'settings': {'clustered': false, 'showEdgeLabels': true, 'showNodeLabels': true}} 46 | } 47 | ]; 48 | 49 | // Work around to get ngLodash correctly injected. 50 | beforeEach(function() { angular.module('testModule', ['ngLodash', 'kubernetesApp.components.graph']); }); 51 | 52 | beforeEach(module('testModule')); 53 | 54 | beforeEach(inject(function(_d3RenderingService_, _d3UtilitiesService_) { 55 | d3RenderingService = _d3RenderingService_; 56 | d3UtilitiesService = _d3UtilitiesService_; 57 | 58 | // Build the parent
and graph
to hold the element. 59 | parentDiv = d3.select('body').append('div'); 60 | parentDiv.style('width', '500px'); 61 | parentDiv.style('height', '500px'); 62 | graphDiv = parentDiv.append('div'); 63 | 64 | // Create the mock scope. 65 | scope = { 66 | viewModelService: { 67 | viewModel: { 68 | data: MOCK_SAMPLE_DATA[0] 69 | }, 70 | setSelectionIdList: function() {} 71 | } 72 | }; 73 | 74 | // Construct and configure the d3 rendering service. 75 | d3Rendering = d3RenderingService.rendering().controllerScope(scope).directiveElement(graphDiv.node()); 76 | 77 | // Set the mock data in the scope. 78 | scope.viewModelService.viewModel.data = MOCK_SAMPLE_DATA[0]; 79 | 80 | // Render the graph. 81 | d3Rendering(); 82 | })); 83 | 84 | afterEach(function() { parentDiv.remove(); }); 85 | 86 | it('should locate the dimensions of the parent', function() { 87 | // Test that container dimensions are properly calculated after rendering. 88 | var containerDimensionsAfterRendering = d3Rendering.getParentContainerDimensions(); 89 | expect(containerDimensionsAfterRendering[0]).toEqual(500); 90 | expect(containerDimensionsAfterRendering[1]).toEqual(500); 91 | }); 92 | 93 | it('should resize the graph implicitly and explicitly', function() { 94 | // Test that the initial graph size is calculated properly. 95 | var initialGraphSize = d3Rendering.graphSize(); 96 | // The initial width is calculated by subtracting 16 from the parent container width. 97 | // TODO(duftler): Use same constant here as in d3RenderingService. 98 | expect(initialGraphSize[0]).toEqual(484); 99 | // The initial height defaults to 700. 100 | // TODO(duftler): Use same constant here as in d3RenderingService. 101 | expect(initialGraphSize[1]).toEqual(700); 102 | 103 | // Explicitly set the graph size. 104 | d3Rendering.graphSize([750, 750]); 105 | 106 | // Test that the modified graph size is calculated properly. 107 | var modifiedGraphSize = d3Rendering.graphSize(); 108 | expect(modifiedGraphSize[0]).toEqual(750); 109 | expect(modifiedGraphSize[1]).toEqual(750); 110 | }); 111 | 112 | it('should respect "selected" property in view model', function() { 113 | // Test that the initial selection is calculated properly. 114 | var initialNodeSelection = d3Rendering.nodeSelection(); 115 | expect(initialNodeSelection.size).toEqual(3); 116 | expect(d3UtilitiesService.setHas(initialNodeSelection, {id: 1})).toBeTruthy(); 117 | expect(d3UtilitiesService.setHas(initialNodeSelection, {id: 2})).toBeTruthy(); 118 | expect(d3UtilitiesService.setHas(initialNodeSelection, {id: 3})).toBeTruthy(); 119 | }); 120 | 121 | it('should completely replace node selection when explicitly set', function() { 122 | // Create and set a new node selection. 123 | var newNodeSelection = new Set(); 124 | newNodeSelection.add({id: 2}); 125 | newNodeSelection.add({id: 55}); 126 | d3Rendering.nodeSelection(newNodeSelection); 127 | 128 | // Test that the updated selection is calculated properly. 129 | var updatedNodeSelection = d3Rendering.nodeSelection(); 130 | expect(updatedNodeSelection.size).toEqual(2); 131 | expect(d3UtilitiesService.setHas(updatedNodeSelection, {id: 2})).toBeTruthy(); 132 | expect(d3UtilitiesService.setHas(updatedNodeSelection, {id: 55})).toBeTruthy(); 133 | }); 134 | 135 | it('should select appropriate edges with respect to selected nodes in view model', function() { 136 | // Test that the expected edges are selected and no more. 137 | var initialEdgeSelectionIterator = d3Rendering.edgeSelection().values(); 138 | 139 | // Test that the first selected edge goes from node with id 1 to node with id 2. 140 | var current = initialEdgeSelectionIterator.next(); 141 | expect(current.done).toBeFalsy(); 142 | expect(current.value.source.id).toEqual(1); 143 | expect(current.value.target.id).toEqual(2); 144 | 145 | // Test that the second selected edge goes from node with id 1 to node with id 3. 146 | current = initialEdgeSelectionIterator.next(); 147 | expect(current.done).toBeFalsy(); 148 | expect(current.value.source.id).toEqual(1); 149 | expect(current.value.target.id).toEqual(3); 150 | 151 | // Test that there are only 2 edges selected. 152 | current = initialEdgeSelectionIterator.next(); 153 | expect(current.done).toBeTruthy(); 154 | }); 155 | 156 | it('should select appropriate edges with respect to explicitly set node selection', function() { 157 | // Create and set a new node selection. 158 | var newNodeSelection = new Set(); 159 | newNodeSelection.add({id: 2}); 160 | newNodeSelection.add({id: 55}); 161 | d3Rendering.nodeSelection(newNodeSelection); 162 | 163 | // Test that the expected edges are selected and no more. 164 | var updatedEdgeSelectionIterator = d3Rendering.edgeSelection().values(); 165 | 166 | // Test that the first selected edge goes from node with id 2 to node with id 55. 167 | var current = updatedEdgeSelectionIterator.next(); 168 | expect(current.done).toBeFalsy(); 169 | expect(current.value.source.id).toEqual(2); 170 | expect(current.value.target.id).toEqual(55); 171 | 172 | // Test that there is only 1 edge selected. 173 | current = updatedEdgeSelectionIterator.next(); 174 | expect(current.done).toBeTruthy(); 175 | }); 176 | 177 | it('should select appropriate edgelabels with respect to selected nodes in view model', function() { 178 | // Test that the expected edgelabels are selected and no more. 179 | var initialEdgelabelsSelectionIterator = d3Rendering.edgelabelsSelection().values(); 180 | 181 | // Test that the first selected edgelabel goes from node with id 1 to node with id 2. 182 | var current = initialEdgelabelsSelectionIterator.next(); 183 | expect(current.done).toBeFalsy(); 184 | expect(current.value.source.id).toEqual(1); 185 | expect(current.value.target.id).toEqual(2); 186 | 187 | // Test that the second selected edgelabel goes from node with id 1 to node with id 3. 188 | current = initialEdgelabelsSelectionIterator.next(); 189 | expect(current.done).toBeFalsy(); 190 | expect(current.value.source.id).toEqual(1); 191 | expect(current.value.target.id).toEqual(3); 192 | 193 | // Test that there are only 2 edgelabels selected. 194 | current = initialEdgelabelsSelectionIterator.next(); 195 | expect(current.done).toBeTruthy(); 196 | }); 197 | 198 | it('should select appropriate edgelabels with respect to explicitly set node selection', function() { 199 | // Create and set a new node selection. 200 | var newNodeSelection = new Set(); 201 | newNodeSelection.add({id: 2}); 202 | newNodeSelection.add({id: 55}); 203 | d3Rendering.nodeSelection(newNodeSelection); 204 | 205 | // Test that the expected edgelabels are selected and no more. 206 | var updatedEdgelabelsSelectionIterator = d3Rendering.edgelabelsSelection().values(); 207 | 208 | // Test that the first selected edgelabel goes from node with id 2 to node with id 55. 209 | var current = updatedEdgelabelsSelectionIterator.next(); 210 | expect(current.done).toBeFalsy(); 211 | expect(current.value.source.id).toEqual(2); 212 | expect(current.value.target.id).toEqual(55); 213 | 214 | // Test that there is only 1 edgelabel selected. 215 | current = updatedEdgelabelsSelectionIterator.next(); 216 | expect(current.done).toBeTruthy(); 217 | }); 218 | 219 | it('should set opacity of selected nodes to 1, and opacity of all others to something else', function() { 220 | // Test that the initial selection is calculated properly. 221 | var initialNodeSelection = d3Rendering.nodeSelection(); 222 | expect(initialNodeSelection.size).toEqual(3); 223 | 224 | graphDiv.selectAll('.node').each(function(e) { 225 | var opacity = d3.select(this).style('opacity'); 226 | 227 | if (opacity === '1') { 228 | expect(d3UtilitiesService.setHas(initialNodeSelection, e)).toBeTruthy(); 229 | } else { 230 | expect(d3UtilitiesService.setHas(initialNodeSelection, e)).toBeFalsy(); 231 | } 232 | }); 233 | 234 | // Create and set a new node selection. 235 | var newNodeSelection = new Set(); 236 | newNodeSelection.add({id: 2}); 237 | newNodeSelection.add({id: 55}); 238 | d3Rendering.nodeSelection(newNodeSelection); 239 | 240 | // Test that the updated node selection is calculated properly. 241 | var updatedNodeSelection = d3Rendering.nodeSelection(); 242 | expect(updatedNodeSelection.size).toEqual(2); 243 | 244 | graphDiv.selectAll('.node').each(function(e) { 245 | var opacity = d3.select(this).style('opacity'); 246 | 247 | if (opacity === '1') { 248 | expect(d3UtilitiesService.setHas(updatedNodeSelection, e)).toBeTruthy(); 249 | } else { 250 | expect(d3UtilitiesService.setHas(updatedNodeSelection, e)).toBeFalsy(); 251 | } 252 | }); 253 | }); 254 | 255 | it('should set opacity of selected edges to 1, and opacity of all others to something else', function() { 256 | // Test that the initial selection is calculated properly. 257 | var initialEdgeSelection = d3Rendering.edgeSelection(); 258 | expect(initialEdgeSelection.size).toEqual(2); 259 | 260 | graphDiv.selectAll('.link').each(function(e) { 261 | var opacity = d3.select(this).style('opacity'); 262 | 263 | if (opacity === '1') { 264 | expect(d3UtilitiesService.setHas(initialEdgeSelection, e)).toBeTruthy(); 265 | } else { 266 | expect(d3UtilitiesService.setHas(initialEdgeSelection, e)).toBeFalsy(); 267 | } 268 | }); 269 | 270 | // Create and set a new node selection. 271 | var newNodeSelection = new Set(); 272 | newNodeSelection.add({id: 2}); 273 | newNodeSelection.add({id: 55}); 274 | d3Rendering.nodeSelection(newNodeSelection); 275 | 276 | // Test that the updated edge selection is calculated properly. 277 | var updatedEdgeSelection = d3Rendering.edgeSelection(); 278 | expect(updatedEdgeSelection.size).toEqual(1); 279 | 280 | graphDiv.selectAll('.link').each(function(e) { 281 | var opacity = d3.select(this).style('opacity'); 282 | 283 | if (opacity === '1') { 284 | expect(d3UtilitiesService.setHas(updatedEdgeSelection, e)).toBeTruthy(); 285 | } else { 286 | expect(d3UtilitiesService.setHas(updatedEdgeSelection, e)).toBeFalsy(); 287 | } 288 | }); 289 | }); 290 | 291 | it('should set opacity of selected edgelabels to 1, and opacity of all others to something else', function() { 292 | // Test that the initial selection is calculated properly. 293 | var initialEdgelabelsSelection = d3Rendering.edgelabelsSelection(); 294 | expect(initialEdgelabelsSelection.size).toEqual(2); 295 | 296 | graphDiv.selectAll('.edgelabel') 297 | .each(function(e) { 298 | var opacity = d3.select(this).style('opacity'); 299 | 300 | if (opacity === '1') { 301 | expect(d3UtilitiesService.setHas(initialEdgelabelsSelection, e)).toBeTruthy(); 302 | } else { 303 | expect(d3UtilitiesService.setHas(initialEdgelabelsSelection, e)).toBeFalsy(); 304 | } 305 | }); 306 | 307 | // Create and set a new node selection. 308 | var newNodeSelection = new Set(); 309 | newNodeSelection.add({id: 2}); 310 | newNodeSelection.add({id: 55}); 311 | d3Rendering.nodeSelection(newNodeSelection); 312 | 313 | // Test that the updated edgelabels selection is calculated properly. 314 | var updatedEdgelabelsSelection = d3Rendering.edgeSelection(); 315 | expect(updatedEdgelabelsSelection.size).toEqual(1); 316 | 317 | graphDiv.selectAll('.edgelabel') 318 | .each(function(e) { 319 | var opacity = d3.select(this).style('opacity'); 320 | 321 | if (opacity === '1') { 322 | expect(d3UtilitiesService.setHas(updatedEdgelabelsSelection, e)).toBeTruthy(); 323 | } else { 324 | expect(d3UtilitiesService.setHas(updatedEdgelabelsSelection, e)).toBeFalsy(); 325 | } 326 | }); 327 | }); 328 | 329 | it('should set opacity of all nodes, edges, edgelabels and images to 1 when nothing is selected', function() { 330 | // Set the node selection to an empty set. 331 | d3Rendering.nodeSelection(new Set()); 332 | 333 | graphDiv.selectAll('.node').each(function(e) { expect(d3.select(this).style('opacity')).toEqual('1'); }); 334 | 335 | graphDiv.selectAll('.link').each(function(e) { expect(d3.select(this).style('opacity')).toEqual('1'); }); 336 | 337 | graphDiv.selectAll('.edgelabel').each(function(e) { expect(d3.select(this).style('opacity')).toEqual('1'); }); 338 | 339 | graphDiv.selectAll('image').each(function(e) { expect(d3.select(this).style('opacity')).toEqual('1'); }); 340 | }); 341 | 342 | it('should update node settings cache when node is pinned and unpinned', function() { 343 | // Pin a node. 344 | d3Rendering.togglePinned({id: 2, fixed: 0}); 345 | 346 | // Test that it's cached as fixed. 347 | var updatedNodeSettingsCache = d3Rendering.nodeSettingsCache(); 348 | expect(updatedNodeSettingsCache[2].fixed).toBeTruthy(); 349 | 350 | // Unpin the same node. 351 | d3Rendering.togglePinned({id: 2, fixed: 8}); 352 | 353 | // Test that it's not cached as fixed. 354 | updatedNodeSettingsCache = d3Rendering.nodeSettingsCache(); 355 | expect(updatedNodeSettingsCache[2].fixed).toBeFalsy(); 356 | }); 357 | 358 | it('should update node settings cache when pins are reset', function() { 359 | // Pin two nodes. 360 | d3Rendering.togglePinned({id: 2, fixed: 0}); 361 | d3Rendering.togglePinned({id: 3, fixed: 0}); 362 | 363 | // Test that they are cached as fixed. 364 | var updatedNodeSettingsCache = d3Rendering.nodeSettingsCache(); 365 | expect(updatedNodeSettingsCache[2].fixed).toBeTruthy(); 366 | expect(updatedNodeSettingsCache[3].fixed).toBeTruthy(); 367 | 368 | // Reset all pins. 369 | d3Rendering.resetPins(); 370 | 371 | // Test that no nodes are cached as fixed. 372 | updatedNodeSettingsCache = d3Rendering.nodeSettingsCache(); 373 | 374 | for (var nodeId in updatedNodeSettingsCache) { 375 | expect(updatedNodeSettingsCache[nodeId].fixed).toBeFalsy(); 376 | } 377 | }); 378 | 379 | // This is the equivalent of the old waitsFor/runs syntax 380 | // which was removed from Jasmine 2. From https://gist.github.com/abreckner/110e28897d42126a3bb9. 381 | var waitsForAndRuns = function(escapeFunction, runFunction, escapeTime) { 382 | // check the escapeFunction every millisecond so as soon as it is met we can escape the function 383 | var interval = setInterval(function() { 384 | if (escapeFunction()) { 385 | clearWaitsForAndRuns(); 386 | runFunction(); 387 | } 388 | }, 1); 389 | 390 | // in case we never reach the escapeFunction, we will time out 391 | // at the escapeTime 392 | var timeOut = setTimeout(function() { 393 | clearWaitsForAndRuns(); 394 | runFunction(); 395 | }, escapeTime); 396 | 397 | // clear the interval and the timeout 398 | function clearWaitsForAndRuns(){ 399 | clearInterval(interval); 400 | clearTimeout(timeOut); 401 | } 402 | }; 403 | 404 | it('should update view settings cache when image is zoomed', function() { 405 | // Adjust the zoom to 75%. 406 | d3Rendering.adjustZoom(0.75); 407 | waitsForAndRuns( 408 | function() { return d3Rendering.viewSettingsCache().scale < 0.77; }, 409 | function() { expect(d3Rendering.viewSettingsCache().scale).toBeLessThan(0.76); }, 410 | 1000); 411 | }); 412 | }); 413 | -------------------------------------------------------------------------------- /js/modules/services/d3RenderingService.js: -------------------------------------------------------------------------------- 1 | /** 2 | Copyright 2015 Google Inc. All rights reserved. 3 | 4 | Licensed under the Apache License, Version 2.0 (the "License"); 5 | you may not use this file except in compliance with the License. 6 | You may obtain a copy of the License at 7 | 8 | http://www.apache.org/licenses/LICENSE-2.0 9 | 10 | Unless required by applicable law or agreed to in writing, software 11 | distributed under the License is distributed on an "AS IS" BASIS, 12 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | See the License for the specific language governing permissions and 14 | limitations under the License. 15 | */ 16 | /**========================================================= 17 | * Module: Graph 18 | * Visualizer for force directed graph. 19 | * This is a service that provides rendering capabilities 20 | * for use by the d3 visualization directive. 21 | =========================================================*/ 22 | (function() { 23 | 'use strict'; 24 | 25 | var d3RenderingService = function(lodash, d3UtilitiesService, $location, $rootScope, inspectNodeService) { 26 | 27 | function rendering() { 28 | var CONSTANTS = { 29 | FIXED_DRAGGING_BIT: 2, 30 | FIXED_MOUSEOVER_BIT: 4, 31 | FIXED_PINNED_BIT: 8, 32 | SHOWPIN_MOUSEOVER_BIT: 2, 33 | SHOWPIN_METAKEYDOWN_BIT: 4, 34 | OPACITY_MOUSEOVER: 0.7, 35 | OPACITY_DESELECTED: 0.2, 36 | // TODO(duftler): Externalize these defaults. 37 | DEFAULTS: { 38 | RENDER_NODE_ICONS: true, 39 | BOUNDING_BOX: [30, 30], 40 | USE_RADIUS_FOR_BOUNDING_BOX: true, 41 | SVG_INITIAL_HEIGHT: 700, 42 | FORCE_CLUSTERED_GRAVITY: 0.80, 43 | FORCE_CLUSTERED_CHARGE: 0, 44 | FORCE_CLUSTERED_THETA: 0.1, 45 | FORCE_CLUSTERED_REFRESH_STARTING_ALPHA: 0.03, 46 | FORCE_CLUSTERED_REDRAW_STARTING_ALPHA: 0.9, 47 | FORCE_NONCLUSTERED_GRAVITY: 0.9, 48 | FORCE_NONCLUSTERED_CHARGE: -1500, 49 | // FORCE_NONCLUSTERED_CHARGE_DISTANCE: 350, 50 | FORCE_NONCLUSTERED_THETA: 0.01, 51 | FORCE_NONCLUSTERED_FRICTION: 0.7, 52 | FORCE_NONCLUSTERED_REFRESH_STARTING_ALPHA: 0.05, 53 | FORCE_NONCLUSTERED_REDRAW_STARTING_ALPHA: 0.3, 54 | FORCE_REFRESH_THRESHOLD_PERCENTAGE: 1, 55 | CLUSTER_INNER_PADDING: 4, 56 | CLUSTER_OUTER_PADDING: 32 57 | } 58 | }; 59 | 60 | var directiveElement; 61 | var controllerScope; 62 | 63 | var nodeIconCache = {}; 64 | 65 | // Used to maintain settings that must survive refresh. 66 | var viewSettingsCache = {}; 67 | var nodeSettingsCache = {}; 68 | 69 | // Contains the currently-seleted resources. 70 | var selection = { 71 | nodes: new Set(), 72 | edges: new Set(), 73 | edgelabels: new Set() 74 | }; 75 | 76 | var node; 77 | var circle; 78 | var pin; 79 | var transform; 80 | var link; 81 | var edgepaths; 82 | var edgelabels; 83 | var force; 84 | var zoom; 85 | var g; 86 | var center; 87 | 88 | // Used to store the largest node for each cluster. 89 | var builtClusters; 90 | // The configured padding between nodes within a cluster. 91 | var clusterInnerPadding; 92 | // The configured padding between clusters. 93 | var clusterOuterPadding; 94 | 95 | // Select all edges and edgelabels where both the source and target nodes are selected. 96 | function selectEdgesInScope() { 97 | selection.edges.clear(); 98 | selection.edgelabels.clear(); 99 | 100 | // Add each edge where both the source and target nodes are selected. 101 | if (link) { 102 | link.each(function(e) { 103 | if (d3UtilitiesService.setHas(selection.nodes, e.source) && 104 | d3UtilitiesService.setHas(selection.nodes, e.target)) { 105 | selection.edges.add(e); 106 | } 107 | }); 108 | } 109 | 110 | // Add each edge label where both the source and target nodes are selected. 111 | if (edgelabels) { 112 | edgelabels.each(function(e) { 113 | if (d3UtilitiesService.setHas(selection.nodes, e.source) && 114 | d3UtilitiesService.setHas(selection.nodes, e.target)) { 115 | selection.edgelabels.add(e); 116 | } 117 | }); 118 | } 119 | } 120 | 121 | // Adjust the opacity of all resources to indicate selected items. 122 | function applySelectionToOpacity() { 123 | var notSelectedOpacity = CONSTANTS.OPACITY_DESELECTED; 124 | 125 | // If nothing is selected, show everything. 126 | if (!selection.nodes.size && !selection.edges.size && !selection.edgelabels.size) { 127 | notSelectedOpacity = 1; 128 | } 129 | 130 | // Reduce the opacity of all but the selected nodes. 131 | node.style('opacity', function(e) { 132 | var newOpacity = d3UtilitiesService.setHas(selection.nodes, e) ? 1 : notSelectedOpacity; 133 | 134 | if (e.originalOpacity) { 135 | e.originalOpacity = newOpacity; 136 | } 137 | 138 | return newOpacity; 139 | }); 140 | 141 | // Reduce the opacity of all but the selected edges. 142 | if (link) { 143 | link.style('opacity', function(e) { 144 | return d3UtilitiesService.setHas(selection.edges, e) ? 1 : notSelectedOpacity; 145 | }); 146 | } 147 | 148 | // Reduce the opacity of all but the selected edge labels. 149 | if (edgelabels) { 150 | edgelabels.style('opacity', function(e) { 151 | return d3UtilitiesService.setHas(selection.edgelabels, e) ? 1 : notSelectedOpacity; 152 | }); 153 | } 154 | 155 | var selectionIdList = []; 156 | 157 | selection.nodes.forEach(function(e) { 158 | if (e.id !== undefined) { 159 | selectionIdList.push(e.id); 160 | } 161 | }); 162 | 163 | controllerScope.viewModelService.setSelectionIdList(selectionIdList); 164 | 165 | _.defer(function() { 166 | $rootScope.$apply(); 167 | autosizeSVG(d3, false); 168 | }); 169 | } 170 | 171 | // Return the dimensions of the parent element of the d3 visualization directive. 172 | function getParentContainerDimensions(d3) { 173 | var parentNode = d3.select(directiveElement.parentNode); 174 | var width = parseInt(parentNode.style('width')); 175 | var height = parseInt(parentNode.style('height')); 176 | 177 | return [width, height]; 178 | } 179 | 180 | // Resize the svg element. 181 | function resizeSVG(d3, newSVGDimensions) { 182 | var svg = d3.select(directiveElement).select('svg'); 183 | var width = newSVGDimensions[0]; 184 | var height = newSVGDimensions[1]; 185 | 186 | svg.attr('width', width); 187 | svg.attr('height', height); 188 | 189 | // We want the width and height to survive redraws. 190 | viewSettingsCache.width = width; 191 | viewSettingsCache.height = height; 192 | 193 | force.size([width, height]); 194 | } 195 | 196 | // Adjust the size of the svg element to a new size derived from the dimensions of the parent. 197 | function autosizeSVG(d3, windowWasResized) { 198 | var containerDimensions = getParentContainerDimensions(d3); 199 | var width = containerDimensions[0] - 16; 200 | var height = containerDimensions[1] - 19; 201 | 202 | resizeSVG(d3, [width, height]); 203 | if (windowWasResized) { 204 | force.resume(); 205 | } 206 | } 207 | 208 | // Get or set the directive element. Returns the rendering service when acting as a setter. 209 | graph.directiveElement = function(newDirectiveElement) { 210 | if (!arguments.length) return directiveElement; 211 | directiveElement = newDirectiveElement; 212 | return this; 213 | }; 214 | 215 | // Get or set the controller scope. Returns the rendering service when acting as a setter. 216 | graph.controllerScope = function(newControllerScope) { 217 | if (!arguments.length) return controllerScope; 218 | controllerScope = newControllerScope; 219 | return this; 220 | }; 221 | 222 | // Return the dimensions of the parent container. 223 | graph.getParentContainerDimensions = function() { 224 | return getParentContainerDimensions(window.d3); 225 | }; 226 | 227 | // Get or set the size of the svg element. Returns the rendering service when acting as a setter. 228 | graph.graphSize = function(newGraphSize) { 229 | if (!arguments.length) { 230 | var svg = window.d3.select(directiveElement).select('svg'); 231 | return [parseInt(svg.attr('width')), parseInt(svg.attr('height'))]; 232 | } else { 233 | resizeSVG(window.d3, newGraphSize); 234 | return this; 235 | } 236 | }; 237 | 238 | // Get or set the node selection. Returns the rendering service when acting as a setter. 239 | graph.nodeSelection = function(newNodeSelection) { 240 | if (!arguments.length) return selection.nodes; 241 | selection.nodes = newNodeSelection; 242 | selectEdgesInScope(); 243 | applySelectionToOpacity(); 244 | return this; 245 | }; 246 | 247 | // Get or set the edge selection. Returns the rendering service when acting as a setter. 248 | graph.edgeSelection = function(newEdgeSelection) { 249 | if (!arguments.length) return selection.edges; 250 | selection.edges = newEdgeSelection; 251 | return this; 252 | }; 253 | 254 | // Get or set the edgelabels selection. Returns the rendering service when acting as a setter. 255 | graph.edgelabelsSelection = function(newEdgelabelsSelection) { 256 | if (!arguments.length) return selection.edgelabels; 257 | selection.edgelabels = newEdgelabelsSelection; 258 | return this; 259 | }; 260 | 261 | // Toggle the pinned state of this node. 262 | function togglePinned(d) { 263 | if (!nodeSettingsCache[d.id]) { 264 | nodeSettingsCache[d.id] = {}; 265 | } 266 | 267 | if (d.fixed & CONSTANTS.FIXED_PINNED_BIT) { 268 | d.fixed &= ~CONSTANTS.FIXED_PINNED_BIT; 269 | force.start().alpha(CONSTANTS.DEFAULTS.FORCE_CLUSTERED_REFRESH_STARTING_ALPHA * 2); 270 | nodeSettingsCache[d.id].fixed = false; 271 | } else { 272 | d.fixed |= CONSTANTS.FIXED_PINNED_BIT; 273 | nodeSettingsCache[d.id].fixed = true; 274 | tick(); 275 | } 276 | } 277 | 278 | graph.togglePinned = togglePinned; 279 | 280 | // Clear all pinned nodes. 281 | function resetPins() { 282 | node.each(function(d) { 283 | // Unset the appropriate bit on each node. 284 | d.fixed &= ~CONSTANTS.FIXED_PINNED_BIT; 285 | 286 | // Ensure the node is not marked in the cache as fixed. 287 | if (nodeSettingsCache[d.id]) { 288 | nodeSettingsCache[d.id].fixed = false; 289 | } 290 | }); 291 | 292 | force.start().alpha(0.01); 293 | } 294 | 295 | graph.resetPins = resetPins; 296 | 297 | function getBoundingBox(d) { 298 | if (d) { 299 | if (_.isArray(d.size)) { 300 | return d.size; 301 | } 302 | 303 | if (CONSTANTS.DEFAULTS.USE_RADIUS_FOR_BOUNDING_BOX && d.radius) { 304 | return [d.radius * 2, d.radius * 2]; 305 | } 306 | } 307 | 308 | return CONSTANTS.DEFAULTS.BOUNDING_BOX; 309 | } 310 | 311 | // Render the graph. 312 | function graph() { 313 | // Adjust selection in response to a single-click on a node. 314 | function toggleSelected(d) { 315 | var isSelected = d3UtilitiesService.setHas(selection.nodes, d); 316 | // Select if no nodes are currently selected or this node is not selected or the shift key is pressed. 317 | var selectOperation = !isSelected || d3.event.shiftKey; 318 | if (selectOperation) { 319 | // Add the clicked node. 320 | if (!isSelected) { 321 | selection.nodes.add(d); 322 | d.selectionHops = 0; 323 | } else { 324 | d.selectionHops = (d.selectionHops || 0) + 1; 325 | } 326 | 327 | // Add each node within the appropriate number of hops from the clicked node. 328 | if (d.selectionHops > 0) { 329 | node.each(function(e) { 330 | if (d3UtilitiesService.neighboring(d, e, linkedByIndex, d.selectionHops) | 331 | d3UtilitiesService.neighboring(e, d, linkedByIndex, d.selectionHops)) { 332 | selection.nodes.add(e); 333 | } 334 | }); 335 | } 336 | } else { 337 | // De-select the clicked node. 338 | selection.nodes.delete(d); 339 | var numberOfHops = d.selectionHops || 0; 340 | delete d.selectionHops; 341 | 342 | // Remove each node within the appropriate number of hops from the clicked node. 343 | if (numberOfHops > 0) { 344 | node.each(function(e) { 345 | if (d3UtilitiesService.neighboring(d, e, linkedByIndex, numberOfHops) | 346 | d3UtilitiesService.neighboring(e, d, linkedByIndex, numberOfHops)) { 347 | selection.nodes.delete(e); 348 | } 349 | }); 350 | } 351 | } 352 | 353 | selectEdgesInScope(); 354 | applySelectionToOpacity(); 355 | } 356 | 357 | // Clear all selected resources. 358 | function resetSelection() { 359 | // Show everything. 360 | _.forEach(selection.nodes, function(d) { 361 | delete d.selectionHops; 362 | }); 363 | selection.nodes.clear(); 364 | selection.edges.clear(); 365 | selection.edgelabels.clear(); 366 | applySelectionToOpacity(); 367 | } 368 | 369 | // Return the configured padding between nodes within a cluster. 370 | function getClusterInnerPadding() { 371 | var result = CONSTANTS.DEFAULTS.CLUSTER_INNER_PADDING; 372 | if (graph.configuration.settings.clusterSettings && 373 | graph.configuration.settings.clusterSettings.innerPadding !== undefined) { 374 | result = graph.configuration.settings.clusterSettings.innerPadding; 375 | } 376 | 377 | return result; 378 | } 379 | 380 | // Return the configured padding between clusters. 381 | function getClusterOuterPadding() { 382 | var result = CONSTANTS.DEFAULTS.CLUSTER_OUTER_PADDING; 383 | if (graph.configuration.settings.clusterSettings && 384 | graph.configuration.settings.clusterSettings.outerPadding !== undefined) { 385 | result = graph.configuration.settings.clusterSettings.outerPadding; 386 | } 387 | 388 | return result; 389 | } 390 | 391 | // The context menu to display when not right-clicking on a node. 392 | var canvasContextMenu = [{ 393 | title: 'Reset Zoom/Pan', 394 | action: function(elm, d, i) { 395 | adjustZoom(); 396 | } 397 | }, { 398 | title: 'Reset Selection', 399 | action: function(elm, d, i) { 400 | resetSelection(); 401 | } 402 | }, { 403 | title: 'Reset Pins', 404 | action: function(elm, d, i) { 405 | resetPins(); 406 | } 407 | }]; 408 | 409 | // The context menu to display when right-clicking on a node. 410 | var nodeContextMenu = [{ 411 | title: function(d) { 412 | return 'Inspect Node'; 413 | }, 414 | action: function(elm, d, i) { 415 | inspectNode(d); 416 | } 417 | }]; 418 | 419 | // Display 'Inspect' view for this node. 420 | function inspectNode(d, tagName) { 421 | if (tagName) { 422 | // Clone the node. 423 | d = JSON.parse(JSON.stringify(d)); 424 | if (d.metadata && d.metadata[tagName]) { 425 | // Prefix the tag name with asterisks so it stands out in the details view. 426 | d.metadata['** ' + tagName] = d.metadata[tagName]; 427 | 428 | // Remove the non-decorated tag. 429 | delete d.metadata[tagName]; 430 | } 431 | } 432 | 433 | // Add the node details into the service, to be consumed by the 434 | // next controller. 435 | inspectNodeService.setDetailData(d); 436 | 437 | // Redirect to the detail view page. 438 | $location.path('/graph/inspect'); 439 | $rootScope.$apply(); 440 | } 441 | 442 | function wheelScrollHandler() { 443 | var origTranslate = zoom.translate(); 444 | zoom.translate([origTranslate[0] - window.event.deltaX, origTranslate[1] - window.event.deltaY]); 445 | zoomed(); 446 | } 447 | 448 | function dragstarted(d) { 449 | d3.event.sourceEvent.stopPropagation(); 450 | d.fixed |= CONSTANTS.FIXED_DRAGGING_BIT; 451 | d.dragging = true; 452 | } 453 | 454 | function dragmove(d) { 455 | d.dragMoved = true; 456 | d.px = d3.event.x, d.py = d3.event.y; 457 | force.start().alpha(CONSTANTS.DEFAULTS.FORCE_CLUSTERED_REFRESH_STARTING_ALPHA * 2); 458 | } 459 | 460 | function dragended(d) { 461 | d.fixed &= ~(CONSTANTS.FIXED_DRAGGING_BIT + CONSTANTS.FIXED_MOUSEOVER_BIT); 462 | d.dragging = false; 463 | d.dragMoved = false; 464 | } 465 | 466 | function getNodeFill(d) { 467 | return d.fill || 'white'; 468 | } 469 | 470 | function getNodeStroke(d) { 471 | return d.stroke || 'black'; 472 | } 473 | 474 | function getNodeStrokeWidth(d) { 475 | return d.strokeWidth || '1'; 476 | } 477 | 478 | function d3_layout_forceMouseover(d) { 479 | // If we use Cmd-Tab but don't navigate away from this window, the keyup event won't have a chance to fire. 480 | // Unsetting this bit here ensures that the Pin cursor won't be displayed when mousing over a node, unless 481 | // the Cmd key is down. 482 | if (!d3.event.metaKey) { 483 | showPin &= ~CONSTANTS.SHOWPIN_METAKEYDOWN_BIT; 484 | } 485 | 486 | showPin |= CONSTANTS.SHOWPIN_MOUSEOVER_BIT; 487 | 488 | // We show the Pin cursor if the cursor is over the node and the command key is depressed. 489 | if (showPin === (CONSTANTS.SHOWPIN_MOUSEOVER_BIT + CONSTANTS.SHOWPIN_METAKEYDOWN_BIT)) { 490 | svg.attr('class', 'graph pin-cursor'); 491 | } 492 | 493 | d.fixed |= CONSTANTS.FIXED_MOUSEOVER_BIT; 494 | d.px = d.x; 495 | d.py = d.y; 496 | 497 | var gSelection = d3.select(this); 498 | 499 | // We capture the original opacity so we have a value to return to after removing the cursor from this node. 500 | d.originalOpacity = gSelection.style('opacity') || 1; 501 | d.opacity = CONSTANTS.OPACITY_MOUSEOVER; 502 | if (CONSTANTS.DEFAULTS.RENDER_NODE_ICONS && d.icon) { 503 | // Circles also get an outline. 504 | var circleSelection = gSelection.select('circle'); 505 | if (circleSelection) { 506 | d.originalStroke = getNodeStroke(d); 507 | d.originalStrokeWidth = getNodeStrokeWidth(d); 508 | circleSelection 509 | .style('stroke', 'black') 510 | .style('stroke-width', '2'); 511 | } 512 | } 513 | 514 | gSelection.style('opacity', d.opacity); 515 | tick(); 516 | } 517 | 518 | function d3_layout_forceMouseout(d) { 519 | showPin &= ~CONSTANTS.SHOWPIN_MOUSEOVER_BIT; 520 | svg.attr('class', 'graph'); 521 | 522 | d.fixed &= ~CONSTANTS.FIXED_MOUSEOVER_BIT; 523 | 524 | var gSelection = d3.select(this); 525 | if (d.originalOpacity) { 526 | d.opacity = d.originalOpacity; 527 | delete d.originalOpacity; 528 | gSelection 529 | .style('opacity', d.opacity); 530 | } 531 | 532 | var circleSelection = gSelection.select('circle'); 533 | if (circleSelection) { 534 | if (d.originalStroke) { 535 | d.stroke = d.originalStroke; 536 | delete d.originalStroke; 537 | circleSelection 538 | .style('stroke', getNodeStroke(d)); 539 | } 540 | 541 | if (d.originalStrokeWidth) { 542 | d.strokeWidth = d.originalStrokeWidth; 543 | delete d.originalStrokeWidth; 544 | circleSelection 545 | .style('stroke-width', getNodeStrokeWidth(d)); 546 | } 547 | } 548 | 549 | tick(); 550 | } 551 | 552 | // Resize the svg element in response to the window resizing. 553 | function windowWasResized() { 554 | autosizeSVG(d3, true); 555 | } 556 | 557 | // Apply all cached settings to nodes, giving precedence to properties explicitly specified in the view model. 558 | // Return true if the given node has neither a specified nor cached position. Return false otherwise. 559 | function applyCachedSettingsToNodes(n, selectedNodeSet) { 560 | var noSpecifiedOrCachedPosition = false; 561 | var cachedSettings; 562 | 563 | if (n.id) { 564 | cachedSettings = nodeSettingsCache[n.id]; 565 | } 566 | 567 | if (n.fixed) { 568 | // If view model specifies node is fixed, it's fixed. 569 | n.fixed = CONSTANTS.FIXED_PINNED_BIT; 570 | } else if (cachedSettings && cachedSettings.fixed) { 571 | // Otherwise, take into account the fixed property from the cache. 572 | n.fixed = CONSTANTS.FIXED_PINNED_BIT; 573 | } 574 | 575 | if (n.position) { 576 | // If view model specifies position use that as the starting position. 577 | n.x = n.position[0]; 578 | n.y = n.position[1]; 579 | 580 | noSpecifiedOrCachedPosition = true; 581 | } else if (cachedSettings) { 582 | // Otherwise, take into account the position from the cache. 583 | var cachedPosition = cachedSettings.position; 584 | 585 | if (cachedPosition) { 586 | n.x = cachedPosition[0]; 587 | n.y = cachedPosition[1]; 588 | } 589 | } 590 | 591 | // If we have neither a view model specified position, nor a cached position, use a random starting position 592 | // within some radius of the canvas center. 593 | if (!n.x && !n.y) { 594 | var radius = graph.nodes.length * 3; 595 | var startingPosition = d3UtilitiesService.getRandomStartingPosition(radius); 596 | 597 | n.x = center[0] + startingPosition[0]; 598 | n.y = center[1] + startingPosition[1]; 599 | 600 | noSpecifiedOrCachedPosition = true; 601 | } 602 | 603 | // Build up a set of nodes the view model specifies are to be selected. 604 | if (n.selected && n.id !== 'undefined') { 605 | selectedNodeSet.add({ 606 | id: n.id 607 | }); 608 | } 609 | 610 | return noSpecifiedOrCachedPosition; 611 | } 612 | 613 | // We want to stop any prior simulation before starting a new one. 614 | if (force) { 615 | force.stop(); 616 | } 617 | 618 | var d3 = window.d3; 619 | d3.select(window).on('resize', windowWasResized); 620 | 621 | // TODO(duftler): Derive the initial svg height from the container rather than the other way around. 622 | var width = viewSettingsCache.width || (getParentContainerDimensions(d3)[0] - 16); 623 | var height = viewSettingsCache.height || CONSTANTS.DEFAULTS.SVG_INITIAL_HEIGHT; 624 | 625 | center = [width / 2, height / 2]; 626 | 627 | var color = d3.scale.category20(); 628 | 629 | d3.select(directiveElement).select('svg').remove(); 630 | 631 | var svg = d3.select(directiveElement) 632 | .append('svg') 633 | .attr('width', width) 634 | .attr('height', height) 635 | .attr('class', 'graph'); 636 | 637 | svg.append('defs') 638 | .selectAll('marker') 639 | .data(['suit', 'licensing', 'resolved']) 640 | .enter() 641 | .append('marker') 642 | .attr('id', function(d) { 643 | return d; 644 | }) 645 | .attr('viewBox', '0 -5 10 10') 646 | .attr('refX', 60) 647 | .attr('refY', 0) 648 | .attr('markerWidth', 6) 649 | .attr('markerHeight', 6) 650 | .attr('orient', 'auto') 651 | .attr('markerUnits', 'userSpaceOnUse') 652 | .append('path') 653 | .attr('d', 'M0,-5L10,0L0,5 L10,0 L0, -5') 654 | .style('stroke', 'black') 655 | .style('opacity', '1'); 656 | 657 | svg.on('contextmenu', function(data, index) { 658 | if (d3.select('.d3-context-menu').style('display') !== 'block') { 659 | d3UtilitiesService.showContextMenu(d3, data, index, canvasContextMenu); 660 | } 661 | 662 | // Even if we don't show a new context menu, we don't want the browser's default context menu shown. 663 | d3.event.preventDefault(); 664 | }); 665 | 666 | zoom = d3.behavior.zoom().scaleExtent([0.5, 12]).on('zoom', zoomed); 667 | 668 | if (viewSettingsCache.translate && viewSettingsCache.scale) { 669 | zoom.translate(viewSettingsCache.translate).scale(viewSettingsCache.scale); 670 | } 671 | 672 | g = svg.append('g'); 673 | 674 | svg.call(zoom).on('dblclick.zoom', null).call(zoom.event); 675 | 676 | var origWheelZoomHandler = svg.on('wheel.zoom'); 677 | svg.on('wheel.zoom', wheelScrollHandler); 678 | 679 | var showPin = 0; 680 | 681 | d3.select('body') 682 | .on('keydown', 683 | function() { 684 | if (d3.event.ctrlKey) { 685 | svg.on('wheel.zoom', origWheelZoomHandler); 686 | svg.attr('class', 'graph zoom-cursor'); 687 | } else if (d3.event.metaKey) { 688 | showPin |= CONSTANTS.SHOWPIN_METAKEYDOWN_BIT; 689 | 690 | if (showPin === (CONSTANTS.SHOWPIN_MOUSEOVER_BIT + CONSTANTS.SHOWPIN_METAKEYDOWN_BIT)) { 691 | svg.attr('class', 'graph pin-cursor'); 692 | } 693 | } 694 | }) 695 | .on('keyup', function() { 696 | if (!d3.event.ctrlKey) { 697 | svg.on('wheel.zoom', wheelScrollHandler); 698 | svg.attr('class', 'graph'); 699 | } 700 | 701 | if (!d3.event.metaKey) { 702 | showPin &= ~CONSTANTS.SHOWPIN_METAKEYDOWN_BIT; 703 | svg.attr('class', 'graph'); 704 | } 705 | }); 706 | 707 | function windowBlur() { 708 | // If we Cmd-Tab away from this window, the keyup event won't have a chance to fire. 709 | // Unsetting this bit here ensures that the Pin cursor won't be displayed when focus returns to this window. 710 | showPin &= ~CONSTANTS.SHOWPIN_METAKEYDOWN_BIT; 711 | svg.attr('class', 'graph'); 712 | } 713 | 714 | window.addEventListener('blur', windowBlur); 715 | 716 | var drag = d3.behavior.drag() 717 | .origin(function(d) { 718 | return d; 719 | }) 720 | .on('dragstart', dragstarted) 721 | .on('drag', dragmove) 722 | .on('dragend', dragended); 723 | 724 | var graph = undefined; 725 | if (controllerScope.viewModelService) { 726 | graph = controllerScope.viewModelService.viewModel.data; 727 | } 728 | 729 | if (graph === undefined) { 730 | return; 731 | } 732 | 733 | force = d3.layout.force().size([width, height]).on('tick', tick); 734 | 735 | if (graph.configuration.settings.clustered) { 736 | force.gravity(CONSTANTS.DEFAULTS.FORCE_CLUSTERED_GRAVITY) 737 | .charge(CONSTANTS.DEFAULTS.FORCE_CLUSTERED_CHARGE) 738 | .theta(CONSTANTS.DEFAULTS.FORCE_CLUSTERED_THETA); 739 | 740 | clusterInnerPadding = getClusterInnerPadding(); 741 | clusterOuterPadding = getClusterOuterPadding(); 742 | } else { 743 | force.gravity(CONSTANTS.DEFAULTS.FORCE_NONCLUSTERED_GRAVITY) 744 | .charge(CONSTANTS.DEFAULTS.FORCE_NONCLUSTERED_CHARGE) 745 | // .chargeDistance(CONSTANTS.DEFAULTS.FORCE_NONCLUSTERED_CHARGE_DISTANCE) 746 | .theta(CONSTANTS.DEFAULTS.FORCE_NONCLUSTERED_THETA) 747 | .friction(CONSTANTS.DEFAULTS.FORCE_NONCLUSTERED_FRICTION) 748 | .linkDistance(function(d) { 749 | return d.distance; 750 | }) 751 | .links(graph.links); 752 | 753 | // Create all the line svgs but without locations yet. 754 | link = g.selectAll('.link') 755 | .data(graph.links) 756 | .enter() 757 | .append('line') 758 | .attr('class', 'link') 759 | .style('marker-end', 760 | function(d) { 761 | if (d.directed) { 762 | return 'url(#suit)'; 763 | } 764 | return 'none'; 765 | }) 766 | .style('stroke', function(d) { 767 | return getNodeStroke(d); 768 | }) 769 | .style('stroke-dasharray', function(d) { 770 | return d.dash || ('1, 0'); 771 | }) 772 | .style('stroke-linecap', function(d) { 773 | return d.linecap || 'round'; 774 | }) 775 | .style('stroke-width', function(d) { 776 | return d.width; 777 | }); 778 | } 779 | 780 | var selectedNodeSet = new Set(); 781 | var newPositionCount = 0; 782 | 783 | // Apply all cached settings and count number of nodes with new positions. 784 | graph.nodes.forEach(function(n) { 785 | if (applyCachedSettingsToNodes(n, selectedNodeSet)) { 786 | ++newPositionCount; 787 | } 788 | }); 789 | 790 | // If any nodes in the graph are explicitly selected, the cached selection is overridden. 791 | if (selectedNodeSet.size) { 792 | selection.nodes = selectedNodeSet; 793 | } 794 | 795 | force.nodes(graph.nodes); 796 | 797 | var startingAlpha = graph.configuration.settings.clustered ? 798 | CONSTANTS.DEFAULTS.FORCE_CLUSTERED_REDRAW_STARTING_ALPHA : 799 | CONSTANTS.DEFAULTS.FORCE_NONCLUSTERED_REDRAW_STARTING_ALPHA; 800 | if (newPositionCount <= (CONSTANTS.DEFAULTS.FORCE_REFRESH_THRESHOLD_PERCENTAGE * graph.nodes.length)) { 801 | startingAlpha = graph.configuration.settings.clustered ? 802 | CONSTANTS.DEFAULTS.FORCE_CLUSTERED_REFRESH_STARTING_ALPHA : 803 | CONSTANTS.DEFAULTS.FORCE_NONCLUSTERED_REFRESH_STARTING_ALPHA; 804 | } 805 | 806 | force.start().alpha(startingAlpha); 807 | 808 | if (graph.configuration.settings.clustered) { 809 | builtClusters = d3UtilitiesService.buildClusters(graph.nodes); 810 | } 811 | 812 | node = g.selectAll('.node') 813 | .data(graph.nodes) 814 | .enter() 815 | .append('g') 816 | .attr('class', 'node') 817 | .on('mouseover', d3_layout_forceMouseover) 818 | .on('mouseout', d3_layout_forceMouseout) 819 | .on('mouseup', mouseup) 820 | .call(drag); 821 | 822 | function mouseup(d) { 823 | if (!d3.event.metaKey) { 824 | if (d.dragMoved === undefined || !d.dragMoved) { 825 | toggleSelected(d); 826 | } 827 | } else { 828 | togglePinned(d); 829 | } 830 | } 831 | 832 | // Create the div element that will hold the context menu. 833 | d3.selectAll('.d3-context-menu').data([1]).enter().append('div').attr('class', 'd3-context-menu'); 834 | 835 | // Close context menu. 836 | d3.select('body') 837 | .on('click.d3-context-menu', function() { 838 | d3.select('.d3-context-menu').style('display', 'none'); 839 | }); 840 | 841 | var attachIconToNode = function(gSelection, svgElement) { 842 | gSelection.append('rect') 843 | .attr('width', function(d) { 844 | var size = getBoundingBox(d)[0]; 845 | return size > 10 ? size - 10 : size; 846 | }) 847 | .attr('height', function(d) { 848 | var size = getBoundingBox(d)[1]; 849 | return size > 10 ? size - 10 : size; 850 | }) 851 | .attr('x', function(d) { 852 | var size = getBoundingBox(d)[0]; 853 | return size > 10 ? 5 : 0; 854 | }) 855 | .attr('y', function(d) { 856 | var size = getBoundingBox(d)[1]; 857 | return size > 10 ? 5 : 0; 858 | }) 859 | .style('stroke', 'white') 860 | .style('fill', 'white'); 861 | 862 | var newElement = svgElement.cloneNode(true); 863 | gSelection.node().appendChild(newElement); 864 | var svgSelection = gSelection.select("svg"); 865 | svgSelection 866 | .attr('width', function(d) { 867 | return getBoundingBox(d)[0]; 868 | }) 869 | .attr('height', function(d) { 870 | return getBoundingBox(d)[1]; 871 | }) 872 | .style('stroke', function(d) { 873 | return getNodeStroke(d); 874 | }) 875 | .style('fill', function(d) { 876 | return getNodeFill(d); 877 | }) 878 | .on('contextmenu', function(data, index) { 879 | d3UtilitiesService.showContextMenu(d3, data, index, nodeContextMenu); 880 | }); 881 | }; 882 | 883 | node.each(function(n) { 884 | var gSelection = d3.select(this); 885 | gSelection.attr('class', 'transform'); 886 | var iconPath = n.icon; 887 | if (CONSTANTS.DEFAULTS.RENDER_NODE_ICONS && iconPath) { 888 | var nodeIconCacheEntry = nodeIconCache[iconPath]; 889 | // Ignoring poossible race condition where d3.xml returns between 890 | // the following two tests and the line that pushes the selection. 891 | if (!nodeIconCacheEntry) { 892 | nodeIconCacheEntry = { 893 | "nodesWaitingForThisIcon": [this], 894 | "svgElement": undefined 895 | }; 896 | nodeIconCache[iconPath] = nodeIconCacheEntry; 897 | d3.xml(iconPath, function(error, documentFragment) { 898 | if (error) { 899 | console.log(error); 900 | return; 901 | } 902 | 903 | var nodeIconCacheEntry = nodeIconCache[iconPath]; 904 | if (nodeIconCacheEntry) { 905 | var svgElement = documentFragment.getElementsByTagName("svg")[0]; 906 | if (svgElement) { 907 | nodeIconCacheEntry.svgElement = svgElement; 908 | _.forEach(nodeIconCacheEntry.nodesWaitingForThisIcon, function(node) { 909 | attachIconToNode(d3.select(node), svgElement); 910 | }); 911 | } 912 | } 913 | }); 914 | } else { 915 | if (!nodeIconCacheEntry.svgElement) { 916 | nodeIconCacheEntry.nodesWaitingForThisIcon.push(this); 917 | } else { 918 | attachIconToNode(gSelection, nodeIconCacheEntry.svgElement); 919 | } 920 | } 921 | } else { 922 | gSelection.append('circle') 923 | .attr('r', function(d) { 924 | return d.radius; 925 | }) 926 | .style('stroke', function(d) { 927 | return getNodeStroke(d); 928 | }) 929 | .style('fill', function(d) { 930 | return d.fill || 'white'; 931 | }) 932 | .on('contextmenu', function(data, index) { 933 | d3UtilitiesService.showContextMenu(d3, data, index, nodeContextMenu); 934 | }); 935 | } 936 | }); 937 | 938 | transform = d3.selectAll('.transform'); 939 | circle = g.selectAll('circle'); 940 | 941 | var text = node.append('text') 942 | .attr('x', function(d) { 943 | var offset = getBoundingBox(d)[0]; 944 | if (!CONSTANTS.DEFAULTS.RENDER_NODE_ICONS || !d.icon) { 945 | offset -= d.radius; 946 | } 947 | 948 | return offset; 949 | }) 950 | .attr('y', function(d) { 951 | var offset = getBoundingBox(d)[1]; 952 | if (!CONSTANTS.DEFAULTS.RENDER_NODE_ICONS || !d.icon) { 953 | offset -= d.radius; 954 | } 955 | 956 | return offset; 957 | }); 958 | 959 | text.text(function(d) { 960 | return graph.configuration.settings.showNodeLabels && !d.hideLabel ? d.name : ''; 961 | }); 962 | 963 | text.each(function(e) { 964 | var singleText = d3.select(this); 965 | var parentNode = singleText.node().parentNode; 966 | 967 | d3.select(parentNode) 968 | .append('image') 969 | .attr('class', 'pin-icon') 970 | .attr('xlink:href', function(d) { 971 | return 'components/graph/img/Pin.svg'; 972 | }) 973 | .attr('display', function(d) { 974 | if (d.fixed) { 975 | if (d.fixed & CONSTANTS.FIXED_PINNED_BIT) { 976 | return null; 977 | } 978 | } 979 | 980 | return 'none'; 981 | }) 982 | .attr('width', function(d) { 983 | return '13px'; 984 | }) 985 | .attr('height', function(d) { 986 | return '13px'; 987 | }) 988 | .attr('x', function(d) { 989 | var offset = -10; 990 | if (!CONSTANTS.DEFAULTS.RENDER_NODE_ICONS || !d.icon) { 991 | offset -= d.radius; 992 | } 993 | 994 | return offset; 995 | }) 996 | .attr('y', function(d) { 997 | var offset = (getBoundingBox(d)[1] / 2) - 13; 998 | if (!CONSTANTS.DEFAULTS.RENDER_NODE_ICONS || !d.icon) { 999 | offset -= d.radius; 1000 | } 1001 | 1002 | return offset; 1003 | }); 1004 | }); 1005 | 1006 | pin = d3.selectAll('.pin-icon'); 1007 | 1008 | if (!graph.configuration.settings.clustered && graph.configuration.settings.showEdgeLabels) { 1009 | edgepaths = g.selectAll('.edgepath') 1010 | .data(graph.links) 1011 | .enter() 1012 | .append('path') 1013 | .attr({ 1014 | d: function(d) { 1015 | return 'M ' + d.source.x + ' ' + d.source.y + ' L ' + d.target.x + ' ' + d.target.y; 1016 | }, 1017 | class: 'edgepath', 1018 | 'fill-opacity': 0, 1019 | 'stroke-opacity': 0, 1020 | fill: 'blue', 1021 | stroke: 'red', 1022 | id: function(d, i) { 1023 | return 'edgepath' + i; 1024 | } 1025 | }) 1026 | .style('pointer-events', 'none'); 1027 | 1028 | edgelabels = g.selectAll('.edgelabel') 1029 | .data(graph.links) 1030 | .enter() 1031 | .append('text') 1032 | .style('pointer-events', 'none') 1033 | .attr({ 1034 | class: 'edgelabel', 1035 | id: function(d, i) { 1036 | return 'edgelabel' + i; 1037 | }, 1038 | dx: function(d) { 1039 | return d.distance / 3; 1040 | }, 1041 | dy: 0 1042 | }); 1043 | 1044 | edgelabels.append('textPath') 1045 | .attr('xlink:href', function(d, i) { 1046 | return '#edgepath' + i; 1047 | }) 1048 | .style('pointer-events', 'none') 1049 | .text(function(d, i) { 1050 | return d.label; 1051 | }); 1052 | } 1053 | 1054 | // If zero nodes are in the current selection, reset the selection. 1055 | var nodeMatches = new Set(); 1056 | 1057 | node.each(function(e) { 1058 | if (d3UtilitiesService.setHas(selection.nodes, e)) { 1059 | nodeMatches.add(e); 1060 | } 1061 | }); 1062 | 1063 | if (!nodeMatches.size) { 1064 | resetSelection(); 1065 | } else { 1066 | selection.nodes = nodeMatches; 1067 | selectEdgesInScope(); 1068 | applySelectionToOpacity(); 1069 | } 1070 | 1071 | // Create an array logging what is connected to what. 1072 | var linkedByIndex = {}; 1073 | for (var i = 0; i < graph.nodes.length; i++) { 1074 | // TODO: Should this be an array instead of a string map? 1075 | linkedByIndex[i + ',' + i] = 1; 1076 | } 1077 | 1078 | if (graph.links) { 1079 | graph.links.forEach(function(d) { 1080 | linkedByIndex[d.source.index + ',' + d.target.index] = 1; 1081 | }); 1082 | } 1083 | } 1084 | 1085 | function tick(e) { 1086 | var forceAlpha = force.alpha(); 1087 | 1088 | node.style('opacity', function(e) { 1089 | if (e.opacity) { 1090 | var opacity = e.opacity; 1091 | delete e.opacity; 1092 | return opacity; 1093 | } 1094 | 1095 | return window.d3.select(this).style('opacity'); 1096 | }); 1097 | 1098 | if (controllerScope.viewModelService.viewModel.data.configuration.settings.clustered) { 1099 | circle.each(d3UtilitiesService.cluster(builtClusters, 10 * forceAlpha * forceAlpha)) 1100 | .each(d3UtilitiesService.collide(d3, controllerScope.viewModelService.viewModel.data.nodes, 1101 | builtClusters, .5, clusterInnerPadding, clusterOuterPadding)); 1102 | 1103 | pin.each(d3UtilitiesService.cluster(builtClusters, 10 * forceAlpha * forceAlpha)) 1104 | .each(d3UtilitiesService.collide(d3, controllerScope.viewModelService.viewModel.data.nodes, 1105 | builtClusters, .5, clusterInnerPadding, clusterOuterPadding)); 1106 | } else { 1107 | link 1108 | .attr('x1', function(d) { 1109 | var offsetX = (CONSTANTS.DEFAULTS.RENDER_NODE_ICONS && d.source.icon) ? getBoundingBox(d.source)[0] / 2 : 0; 1110 | return d.source.x + offsetX; 1111 | }) 1112 | .attr('y1', function(d) { 1113 | var offsetY = (CONSTANTS.DEFAULTS.RENDER_NODE_ICONS && d.source.icon) ? getBoundingBox(d.source)[1] / 2 : 0; 1114 | return d.source.y + offsetY; 1115 | }) 1116 | .attr('x2', function(d) { 1117 | var offsetX = (CONSTANTS.DEFAULTS.RENDER_NODE_ICONS && d.target.icon) ? getBoundingBox(d.target)[0] / 2 : 0; 1118 | return d.target.x + offsetX; 1119 | }) 1120 | .attr('y2', function(d) { 1121 | var offsetY = (CONSTANTS.DEFAULTS.RENDER_NODE_ICONS && d.target.icon) ? getBoundingBox(d.target)[1] / 2 : 0; 1122 | return d.target.y + offsetY; 1123 | }); 1124 | 1125 | if (edgepaths) { 1126 | edgepaths.attr('d', function(d) { 1127 | var path = 'M ' + d.source.x + ' ' + d.source.y + ' L ' + d.target.x + ' ' + d.target.y; 1128 | return path 1129 | }); 1130 | 1131 | edgelabels.attr('transform', function(d, i) { 1132 | if (d.target.x < d.source.x) { 1133 | var bbox = this.getBBox(); 1134 | var rx = bbox.x + bbox.width / 2; 1135 | var ry = bbox.y + bbox.height / 2; 1136 | 1137 | return 'rotate(180 ' + rx + ' ' + ry + ')'; 1138 | } else { 1139 | return 'rotate(0)'; 1140 | } 1141 | }); 1142 | } 1143 | } 1144 | 1145 | transform.attr('transform', function(d) { 1146 | return sprintf('translate(%s,%s)', d.x, d.y); 1147 | }); 1148 | 1149 | pin.attr('display', function(d) { 1150 | return d.fixed & CONSTANTS.FIXED_PINNED_BIT ? null : 'none'; 1151 | }); 1152 | 1153 | if (forceAlpha < 0.04) { 1154 | controllerScope.viewModelService.viewModel.data.nodes.forEach(function(n) { 1155 | if (n.id) { 1156 | if (!nodeSettingsCache[n.id]) { 1157 | nodeSettingsCache[n.id] = {}; 1158 | } 1159 | 1160 | nodeSettingsCache[n.id].position = [n.x, n.y]; 1161 | } 1162 | }); 1163 | } 1164 | } 1165 | 1166 | // Get or set the node settings cache. Returns the rendering service when acting as a setter. 1167 | graph.nodeSettingsCache = function(newNodeSettingsCache) { 1168 | if (!arguments.length) return nodeSettingsCache; 1169 | nodeSettingsCache = newNodeSettingsCache; 1170 | 1171 | return this; 1172 | }; 1173 | 1174 | // Get or set the view settings cache. Returns the rendering service when acting as a setter. 1175 | graph.viewSettingsCache = function(newViewSettingsCache) { 1176 | if (!arguments.length) return viewSettingsCache; 1177 | viewSettingsCache = newViewSettingsCache; 1178 | 1179 | return this; 1180 | }; 1181 | 1182 | function zoomed() { 1183 | var translate = zoom.translate(); 1184 | var scale = zoom.scale(); 1185 | 1186 | g.attr('transform', 'translate(' + translate + ')scale(' + scale + ')'); 1187 | 1188 | viewSettingsCache.translate = translate; 1189 | viewSettingsCache.scale = scale; 1190 | } 1191 | 1192 | function adjustZoom(factor) { 1193 | var scale = zoom.scale(), 1194 | extent = zoom.scaleExtent(), 1195 | translate = zoom.translate(), 1196 | x = translate[0], 1197 | y = translate[1], 1198 | target_scale = scale * factor; 1199 | 1200 | var reset = !factor; 1201 | 1202 | if (reset) { 1203 | target_scale = 1; 1204 | factor = target_scale / scale; 1205 | } 1206 | 1207 | // If we're already at an extent, done. 1208 | if (target_scale === extent[0] || target_scale === extent[1]) { 1209 | return false; 1210 | } 1211 | // If the factor is too much, scale it down to reach the extent exactly. 1212 | var clamped_target_scale = Math.max(extent[0], Math.min(extent[1], target_scale)); 1213 | if (clamped_target_scale != target_scale) { 1214 | target_scale = clamped_target_scale; 1215 | factor = target_scale / scale; 1216 | } 1217 | 1218 | // Center each vector, stretch, then put back. 1219 | x = (x - center[0]) * factor + center[0]; 1220 | y = (y - center[1]) * factor + center[1]; 1221 | 1222 | if (reset) { 1223 | x = 0; 1224 | y = 0; 1225 | } 1226 | 1227 | // Transition to the new view over 350ms 1228 | window.d3.transition().duration(350).tween('zoom', function() { 1229 | var interpolate_scale = window.d3.interpolate(scale, target_scale); 1230 | var interpolate_trans = window.d3.interpolate(translate, [x, y]); 1231 | 1232 | return function(t) { 1233 | zoom.scale(interpolate_scale(t)).translate(interpolate_trans(t)); 1234 | 1235 | zoomed(); 1236 | }; 1237 | }); 1238 | } 1239 | graph.adjustZoom = adjustZoom; 1240 | 1241 | return graph; 1242 | } 1243 | 1244 | return { 1245 | rendering: rendering 1246 | }; 1247 | }; 1248 | 1249 | angular.module('kubernetesApp.components.graph.services.d3.rendering', []) 1250 | .service('d3RenderingService', ['lodash', 'd3UtilitiesService', '$location', '$rootScope', 'inspectNodeService', d3RenderingService]); 1251 | 1252 | })(); --------------------------------------------------------------------------------