Overview
This article documents the development of a small exploratory project for flowchart visualization and editing that is built upon SVG and AngularJS. It makes good use of the MVVM pattern so that UI logic can be unit-tested.
After so many articles on WPF it may come as a surprise that I now have an article on web UI. For the last couple of years I have been ramping up my web development skills.
Professionally I have been using web UI in some pretty interesting ways connected to game development. For example building game dev tools and in-game web UIs, but I'm not talking about that today.
It seemed only natural that I should take my NetworkView WPF article and bring it over to web UI. I've always been interested in visualization and editing of networks, graphs and flow-charts and it is one of the ways that I put my skills to the test in any particular area.
During development of the code I have certainly moved my skills forward in many areas, including Javascript, TDD, SVG and AngularJS. Specifically I have learned how to apply the goodness of the MVVM pattern to web UI development. In this article I will show how I have deployed the MVVM concepts in HTML5 and Javascript.
A little over a year ago I started developing using TDD, something I always wanted to do when working with WPF, but never got around to it (or really appreciated the power of it). TDD has really helped me to realize the full potential of MVVM.
My first attempt at NetworkView, in WPF, took a long time. Over two years (in my spare-time of course!) I wrote a series of 5 articles that were all building up to NetworkView. A lot of effort went into achieving those articles! This time around the development and writing of the article has been much quicker - only a few months (stealing 30 minutes here and there from my busy life). I attribute the faster development time to the following reasons:
- I set my sights much lower. The new code isn't as general purpose or as feature rich as the original NetworkView.
- I already knew how to build this kind of thing so I was able to start at a running pace.
- I have loads of experience with the MVVM pattern so I didn't have to spend much time thinking it through! This can't be understated, boy was it hard coming to grips with WPF general purpose controls and the MVVM pattern the first time around!
- Working with web UI and Javascript (it pains me to say this) is so much easier and less complex than working with WPF (although I still love C#, I sincerely wish they'd overhaul WPF).
- Finally, using TDD allowed me to quickly and easily overcome many of the traditional difficulties with Javascript development. Importantly I was able to refactor aggressively without having to deal with the usual defects that arise from behavior changes.
So let's re-live my exploration of SVG + AngularJS flowcharts.
Screenshot
This is an annotated screenshot of the flowchart web app. On the left is an editable JSON representation of the flowchart's data-model. On the right is the graphical representation of the flowchart's view-model. Editing either side (left as text, right visually) updates the other, they automatically stay in sync.
Audience
So who should read this article?
If you are interested in developing graphical web applications using SVG and AngularJS, this article should help.
You should already know a bit of HTML5/Javascript or be willing to learn quickly as we go. A basic knowledge of SVG and AngularJS will help, although I'll expect you are learning some of that right now and I'll do my best to help you get on track with it. I'll expect you already know something about MVVM, I have talked about it extensively in previous articles, if not then don't worry I'll give an overview of what it is and why it is useful.
I will also mention TDD as well to help you understand how it might help you as a developer.
What and Why?
This article is about a web UI redevelopment of my original NetworkView WPF control. As mentioned the new code isn't as feature rich or general purpose as the original WPF control. Developing something that was completely functional wasn't the intention, I really was just looking for a way to exercise my skills in Javascript, TDD, AngularJS and SVG and consolidate my web development skills.
I really enjoy working with web UI. Since I was first looking at web technologies in the early days of my career to now I have seen many changes in the tech landscape. Web technologies have progressed a remarkably long way and the community is alive and brimming with enthusiasm.
My NetworkView article was popular and a rebuild in web UI seemed like a good idea. I was building something I already knew about so I could achieve it much quicker than if I had started something new. However there are many parts of the original article that don't have a counterpart in the new code. There is no zooming and panning, there is no equivalent to adorners to provide feedback. There is no templating for different types of nodes.
In summary, this code will be useful to you if:
- You want to learn about the technologies I am talking about in this article.
- If you need a flowchart and want to adapt my code to your needs.
- You want to learn how to deploy AngularJS in a situation that is non-trivial and a little bit outside its normal use-case.
Whatever your reason for reading this article, you have some work ahead of you either in understanding or modifying my code. If you are trying to make progress with AngularJS + SVG or even just web UI graphics in general, then I'm sure this will help.
Live Demo
First up let's look at the live demo. This allows you to see what you are getting without having to get the code locally and run your own web server (which isn't difficult anyway).
Here is the main demo:
https://dl.dropboxusercontent.com/u/16408368/WebUI_FlowChart/index.html
Here are the unit-tests:
https://dl.dropboxusercontent.com/u/16408368/WebUI_FlowChart/jasmine/SpecRunner.html
Running the code
Everything you need to run the code locally is attached to this article as a zip file. However I recommend going to the github repository for the most up-to-date code. I recommend using SourceTree as an interface to Git.
Running the sample app requires that you run it through a local web server and view it through your browser. You could just disable your browser's web security and load the app in your browser directly from the file system using file://. However I can't recommend that as you would have to override the security in your web browser, besides it is easier than ever to run a local web server. Let me show you how.
I have provided a simple bare bones web server (that I found on StackOverflow) that is built on NodeJS. When you have NodeJS installed open a cli and change directory to where the code is. Run the following command:
node server.js
You now have a local web server. Point your web browser at the following URL to see the web app:
And to run the unit-tests:
http://localhost:8888/jasmine/SpecRunner.html
Update: I found an even easier way to run a web server. Install http-server using the following command:
npm install http-server -g
Navigate to the directory with the code and run the web-server:
http-server
Javascript
Javascript is the language of the internet and recently I have developed an appreciation for it. Sure, it has some bad parts, but if you follow Crockford's advice you can stick to the good parts.
First-class functions are an important feature and very powerful. I'm really glad we (kind of) have these in C# now. Even the latest C++ standard supports lambdas, it seems that function programming is creeping in everywhere these days. Coming at Javascript from a classical language you may find that prototypal inheritance is rather unusual, but it is more powerful, even if difficult to understand.
Once you are setup and used to it, it's hard to beat the Javascript workflow. Install Google Chrome, install a good text editor, you now have a development environment! Including profiling and debugging. Combine this with node-livereload and a suite of unit-tests and you have a system where your web application and unit-tests will re-run automatically as you type your code. I can't emphasize enough how important this is for productivity. Extremely fast feedback cycles are so important for effective Agile development.
Test-driven development
Test-driven development has been one of the most positive changes in my career so far. It was always hard to keep design simple and minimize defects in code that is rapidly evolving. As the code grows large it becomes harder to manage, harder to keep under control and difficult to refactor. This problem is only worse when using a language like Javascript.
Unit-testing is hard. It takes effort and discipline. You can't afford to leave it until after you have developed the code - it is too easy to be lazy and just skip the tests when things seem to be working. TDD turns this around. You have to be disciplined, you have to slow down, you are forced to write your unit-tests (otherwise you just aren't doing TDD). You have to think up front about your coding, there is no way around it. Thinking before coding is always a good thing and generally all to rare.
TDD makes you design your code for testability. This sometimes means slightly over-engineered code, but TDD means you can safely refactor on the go, this keeps the design simple and under control. The trick to refactoring is to make the design look perfect for the evolved requirements, even though the code has changed drastically above and beyond the original design. When I say slightly over-engineered that's exactly what I mean, only slightly. I have seen and participated in massively over-engineered coding. TDD for the most part has a negative effect on over-engineering. TDD means you only code what you are testing. This ensures your code is always needed, always to the point. Your efforts are focused on the end requirements and you don't end up coding something you don't need (unless your testing for something you don't need, and why would you do that?). This attitude of code only what you need solves one of the most insidious problems that developers have ever faced: it helps to prevent development of code that will never be used. Eliminate waste is principle number 1 in lean software development.
Creating a permanent scaffolding of unit-tests for your program prevents code rot and enables refactoring. Another thing it is good for: preserving your sanity and increasing your confidence in the code.
Now granted that this web application is smallish and not overly complicated, however professionally I have used TDD on much more complex programs. This web application was built in my spare time, only spending 30-60 minutes at a time on it. Occasionally I took a week or two off to concentrate on other things. Switching projects takes significant mental effort, but TDD makes it much easier to switch back. When you come back to the project you run the tests and then pick a simple next test to implement. There is no better way of getting back into it again from a cold start.
TDD has helped me keep the code-base in check as it changed, adding feature after feature, heading towards my end goal. Along the way I refactored aggressively without adding defects. This is important. So often I have experienced refactoring go horribly wrong, I'm talking about the kind of event that causes defects for weeks if not months. One of TDD's most attractive benefits is a reduction in the pain associated with constant code evolution.
I once heard someone say TDD is like training wheels for programmers. I laughed at the time, but after some thought I decided this comment, though funny, was far from the truth. I have worked on a TDD team for a year now and I can honestly say that TDD is significantly harder than the usual fire from the hip programming. It takes effort to learn and makes you slower (what I would call the true cost of development). TDD is very powerful and it isn't right for every project (it has little value in prototype or exploratory projects), but the payoff for longer term projects is potentially enormous if you are willing to make the investment.
Last word. Having the unit-tests was essential for making the app work across browsers. I didn't have to do cross-browser testing during development. Near the end it was mostly enough to get the unit-tests working under each browser.
MVVM
Four years ago when I was first learning WPF I never would have imagined how far down the rabbit hole I was going to end up. Initially the learning curve was steep, but after two years and multiple articles I had a good understanding of WPF and MVVM.
MVVM is a pattern evolved from MVC and it isn't actually that difficult to understand, although I think something about the combination of MVVM and WPF and the resulting complexities gives people (including myself) a lot of trouble in the beginning.
The basic concept of MVVM is pretty simple: Separate your UI logic from your UI rendering so that you can unit-test your UI logic. That's essentially it! It answers the question: how do I unit-test my GUI?
Fitting MVVM into Javascript looks a bit different, but is similar and simpler than MVVM under WPF. Javascript/HTML may not be the ideal way to build an application, but it is more productive than working with C#/WPF. I hope to show that MVVM + web UI gives you the benefits of MVVM, minus the complexity of WPF (although you may not appreciate this unless you have worked with WPF).
AngularJS
I am very pleased to have discovered AngularJS right at the point where I was moving into web UI.
What is AngularJS?
Probably best to learn that direct from the source.
Why use AngularJS?
Well in this article I'm mostly interested in its data-binding capabilities. AngularJS provides the magic necessary to glue your HTML view to your view-model and its data-bindings are trivial to use.
Why else would you use AngularJS?
Google reasons to use AngularJS or benefits of AngularJS and you will find many.
How does AngularJS fit in with MVVM? I'm glad you asked. It looks like this:
The controller sets up a scope. The scope contains variables that are data-bound to the HTML view. In the flowchart application the scope contains a reference to the flowchart's view-model, which in-turn wraps up the flowchart's data-model.
Hang on, there is a controller?
Doesn't
that mean it is MVC rather than
MVVM? Well to be sure AngularJS is a bit different to what we know of
as MVVM. The AngularJS pattern is also different to traditional MVC.
This happens all the time: new patterns are created, old patterns are
evolved or built-on, that's part of progress in software development.
It comes down to professional developers making their own patterns as
they need them and they do it all the time mostly without even thinking
much about it. In some cases they are based on established patterns
like
MVC, other times they are completely unique to the problem at
hand. So it's no mystery that these two patterns are different
even though they are similar on a deeper level. In the same way that
Microsoft gave birth to the MVVM pattern through WPF, Google have
created their own MVC-like pattern through AngularJS.
In the end it is just terminology and semantics and I consider the AngularJS controller to simply be a part of the view-model. The way I see it, the application is comprised of a data-model, a view-model and the view. Ultimately it really depends on how you think about things, I come from a MVVM/WPF background so I see my work in light of that, you will no doubt see it differently.
AngularJS has been pleasantly simple to use with and I have encountered very few issues. Since I have been using it there has been multiple releases that have actually fixed problems that I was having. I have even delved into the source code from time to time to gain a better understanding. Although not trival the code is certainly very readable and understandable.
I'll talk more about AngularJS issues and solutions at the end of the article.
Want more info about AngularJS?
They have awesome documentation.
SVG
I have only paid attention to SVG in recent years, but it is impressive that it has actually been around for a long time (since 1999 according to wikipedia).
After working with XAML I was amused to discover how many features that Microsoft lifted straight out of SVG. That's how things work, we wouldn't get anywhere in particular if we weren't innovating on top of previous discoveries and inventions.
I suspect that SVG was somewhat forgotten and is now experiencing something of a renaissance. These days, SVG has good browser support although there are still issues to be aware of and some features to stay clear of. To a certain extent you can embed SVG directly in HTML and treat it as though it were just HTML! Unfortunately you get bitten by bad library support (I'm looking at you jQuery) but I'm pleased to say that AngularJS have made progress with their SVG support whilst I have been developing the flowchart web application.
I'll talk about the SVG issues at the end of the article.
Development Environment
This is a quick outline of my development environment.
Core tools:
- BRACKETS
- Google Chrome (with Firefox and IE for testing)
- node-livereload for automatically refreshing the browser when the code/html changes (the GUI LiveReload, although potentially awesome, is buggy as hell under Windows and barely works).
- SourceTree for interacting with github
- Workflowy for managing my todo lists
- Internet Explorer and Firefox for testing
Core libraries:
Other tools:
Honorable mentions:
- GruntJS is an awesome tool for scripting your build process (I don't need it in this web app, although I use it generally for building both C# and Javascript applications)
Application Walkthrough
Overview
In this section we walk-through the HTML and code for the flowchart application and understand how the application interacts with the flowchart view-model. We will mostly be looking at index.html and app.js.
In the process we'll get a feel for how an AngularJS application works.
The following diagram shows the AngularJS modules in the application and the dependencies between them:
The next diagram overviews the files in the project:
And drilling down into the flowchart directory:
Application Setup
Our entry point into the application is index.html. This contains the HTML that defines the application UI and references the necessary scripts.
Traditionally scripts are included within the head element, although here only a single script is included in the head. This is the script that enables live reload support:
<script src="http://localhost:35729/livereload.js?snipver=1"></script>
Live reload enables automatic refresh of the page within the browser whenever the the source files have changed. This is what makes Javascript development so productive, you can change the code and have the application reload and restart automatically, no compilation is needed, no manual steps are needed. The feedback loop is substantially reduced.
Live reload can also be achieved by using a browser plugin instead of adding a script. I opt for the script usually so that development can happen on any machine without requiring a browser plugin. You probably don't want live reload in production of course, so your production server should remove this script.
To try out live reload locally ensure you have installed the NodeJS plugin and run node-live-reload from the directory that contains the web page.
All other scripts are included from the end of the body element. This allows the scripts to be loaded asynchronously as the body of the web page is loaded. Whether scripts are included in the head or the body depends how you need your application to work.
The first two scripts are jQuery and AngularJS, the core libraries that this application builds on:
<script src="lib/jquery-2.0.2.js" type="text/javascript"></script> <script src="lib/angular-1.2.3.js" type="text/javascript"></script>
Next are the scripts that contain reusable code, including SVG, mouse handling and the flowchart:
<script src="debug.js" type="text/javascript"></script> <script src="flowchart/svg_class.js" type="text/javascript"></script> <script src="flowchart/mouse_capture_directive.js" type="text/javascript"></script> <script src="flowchart/dragging_directive.js" type="text/javascript"></script> <script src="flowchart/flowchart_viewmodel.js" type="text/javascript"></script> <script src="flowchart/flowchart_directive.js" type="text/javascript"></script>
The application code is included last:
<script src="app.js" type="text/javascript"></script>
Now back to the top of index.html, the body element contains a number of important attributes:
<body ng-app="app" ng-controller="AppCtrl" mouse-capture ng-keydown="keyDown($event)" ng-keyup="keyUp($event)" >
ng-app designates the root element that contains the AngularJS application. The value of this attribute specifies the AngularJS module that contains the application code. This is the most important attribute in the application because this is what bootstraps AngularJS. Without ng-app there is no AngularJS application. In this instance we have specified app which links the DOM to our app module which is registered in app.js, we'll take a look at that in a moment. With the ng-app and the AngularJS source code included in the page, the AngularJS app is bootstrapped automatically. If necessary, for example to control initialization order, you can also manually bootstrap the AngularJS app. It is interesting to note here that ng-app is applied to the entire body of the web page. This suits me because I want the entire page to be an AngularJS application, however it is also possible to put ng-app on any sub-element and thus only allow a portion of your page to be controlled by AngularJS.
ng-controller assigns an AngularJS controller to the body of the page. Here AppCtrl is assigned which is the root controller for the entire application.
mouse-capture is a custom attribute I have created to manage mouse capture within the application.
ng-keydown and ng-keyup link the DOM events to Javascript handler functions.
If you already know HTML but don't know AngularJS, by now you may have guessed that AngularJS gives you the ability to create custom HTML attributes to wire behavior and logic to your declarative user interface. If you don't realize how amazing this is I suggest you go and do some traditional web programming before coming back to AngularJS. AngularJS allows the extension of HTML with new elements and attributes using AngularJS directives.
The flow-chart element is a custom element used to insert a flowchart into the page:
<flow-chart style="margin: 5px; width: 100%; height: 100%;" chart="chartViewModel" > </flow-chart>
The flow-chart element is defined by a directive that injects a HTML/SVG template into the DOM at this point. The directive coordinates the components that make up the flowchart. It attaches mouse input handlers to the DOM and translates them into actions performed against the view-model.
The chart attribute of the flow-chart element data-binds the view-model from the application's scope into the flowchart's scope. A scope is a Javascript object that contains the variables and functions that are accessible from HTML/SVG via data-binding. In this case we are binding chartViewModel to the chart attribute as illustrated by the following diagram:Application Module Setup
Let's look at app.js to see the application's setup of the data-model. The first line registers the app module:
angular.module('app', ['flowChart', ])
Using the module function the app module is registered. This is the same app that was referenced by ng-app="app" in index.html. The first parameter is the name of the module. The second parameter is a list of modules that this module depends on. In this case the app module depends on the flowChart module. The flowChart module contains the flowchart directive and associated code, which we look at later.
After the module, an AngularJS service is registered. This is the simplest example of a service and in a moment you will see how it is used. This service simply returns the browser's prompt function:
.factory('prompt', function () { return prompt; }
Next the application's controller is registered:
.controller('AppCtrl', ['$scope', 'prompt', function AppCtrl ($scope, prompt) { // ... controller code ... }]) ;
The second parameter to the controller function is an array that contains two strings and a function. The parameters of the function have the same names as the strings in the array.
If it wasn't for minification we could define the controller more simply like this:
.controller('AppCtrl', function AppCtrl ($scope, prompt) { // ... controller code ... }) ;
In the second case the array has been replaced only by the function which is simpler but works only during development and not in production. AngularJS instances the controller by calling the registered Javascript constructor function. AngularJS knows to instance this particular controller because it was specified by name in the HTML using ng-controller="AppCtrl". The controller's parameters are then satisfied by dependency injection based on parameter name. The AngularJS implementation of dependency injection is so simple, seamless and reliable that it has convinced me in general that a good dependency injection framework should be a permanent part of my programming toolkit.
Of course the simple case doesn't work in production where the application has been minified. The parameter names will have been shortened or mangled, so we must provide the explicit list of dependency names before the constructor function. It is a pity really that we have to do this, as the implicit method of dependency specification is more elegant.
The $scope parameter is the scope automatically created by AngularJS for the controller. In this case the scope is associated with the body element. The dollar prefix here indicates that $scope is provided by AngularJS itself. The dollar sign in Javascript is simply a character that can be used in an identifier, it has no special meaning to the interpreter. I suggest you don't use $ for your own variables because then you can't easily identify the variables provided by AngularJS.
The prompt parameter is the prompt service that we saw a moment ago. AngularJS automatically instances the prompt service from the factory we registered earlier. The question you might be asking now is why decouple the prompt service from the application controller? Well generally it so that we can unit-test the application controller, even though I don't bother testing the application code in this case (although I do test the flowchart code, which you'll see later). The decoupling means the prompt service can be mocked thus isolating the code that we want to test. In this case, the only reason I decoupled the prompt service is simply because I wanted to take the opportunity to demonstrate in the simplest scenario how and why to use a service.
Application Controller Setup
Now let's break down the application controller from app.js:
.controller('AppCtrl', ['$scope', 'prompt', function AppCtrl ($scope, prompt) { // ... Various private variables used by the controller ... // ... Create example data-model of the chart ... // ... Define application level key event handlers ... // ... Functions for adding/removing nodes and connectors ... // ... Create of the view-model and assignment to the AngularJS scope ... }]) ;
For the moment we will skip the details of the chart data-model. We will come back to that next section.
The most important thing that happens in the application controller is the instantiation of the view-model at the end of the function:
$scope.chartViewModel = new flowchart.ChartViewModel(chartDataModel);
ChartViewModel wraps the data-model and is assigned to the scope making it accessible from the HTML. This allows us to data-bind the chart attribute to chartViewModel as we have seen in index.html:
<flow-chart style="margin: 5px; width: 100%; height: 100%;" chart="chartViewModel" > </flow-chart>
The application controller creates the flowchart view-model so that it may have direct access to its services. This was an important design decision. Originally the application created only the data-model which was passed directly to the flowchart directive, internally then the flowchart directive wrapped the data-model in the view-model. I found that this strategy gave the application inadequate control over the UI. As an example consider deleting selected flowchart items. The delete key is handled and the application must call into the view-model to delete the currently selected flowchart items. The initial strategy was to delete the elements directly from the data-model and have the directive detect this and update the view-model accordingly, however this failed because there is no way to know from the data which items are selected! In addition it made the flowchart directive more complicated because it would now have to watch the data-model changes, normally it just watches the view-model and this happens automatically anyway. A naive approach would have been to add fields to the data-model to indicate which items are selected, but this would be bad design: polluting the data-model with view specific concepts! In any case, changing the data-model to support selection (or other view features) would mean that you can't then share the data-model between completely different kinds of views, so you can see that even in principle it is just wrong to combine the view-model and data-model concepts. The better solution is to have a view-model that is distinct from the flowchart directive and mimics the structure of the data-model. The application is then put in direct control of that view-model so it can be manipulated directly.
The following diagram indicates the dependencies between the application and the flowchart components:
As an example of how the application interacts with the view-model we will look at the previously mentioned delete selected feature, that allows deletion of flowchart items. ng-keyup is handled for the body element:
ng-keyup="keyUp($event)"
The browser's onkeyup event is bound to keyUp in the application scope. The $event object is made available for use by AngularJS and is passed as a parameter to keyUp. This should be pretty much the same as the jQuery event object, although the AngularJS docs doesn't have much to say about it.
This diagram illustrates the binding:The keyUp function is defined in app.js and assigned directly to the application scope:
$scope.keyUp = function (evt) { if (evt.keyCode === deleteKeyCode) { // // Delete key. // $scope.chartViewModel.deleteSelected(); } // ... handling for other keys ... };
The keyUp function simply calls deleteSelected on the view-model. This is an example of the application directly manipulating the flowchart view-model, later we'll have a closer look at this function.
Flowchart Data Model Setup
Let's back up and look at the setup of the flowchart's data-model.
The example-data model is defined inline in app.js:
var chartDataModel = { nodes: [ // Nodes defined here. ], connections: [ // Connections defined here. ] };
Then it is wrapped by the view-model:
$scope.chartViewModel = new flowchart.ChartViewModel(chartDataModel);
We could also have asynchronously loaded the data-model as a JSON file.
Before digging further into the structure of the data-model, you may want to develop a better understanding of the components of a flowchart. Rather than prepare fresh diagrams, I'll refer to those from my older article. Please take a look at the Overview of Concepts in that article and then come back. ...
Ok, so you read the overview right? And you know the difference between nodes, connectors and connections.
Here is the definition of a single node as defined in app.js:
Here is the definition of a single connection:
Connections in the data-model reference their attached nodes by IDs. Connectors are referenced by index. An alternative approach would be to drop the node reference and reference only the connector by an ID that is unique for each connector in the flowchart.
Flowchart Walkthrough
Overview
This section examines the implementation of the flowchart directive, controller, view-model and template.
An AngularJS directive is registered with the name flow-chart. When AngularJS bootstraps and encounters the flow-chart element in the DOM it automatically instantiates the directive. The directive then specifies a template and this replaces the flow-chart tag in the HTML. The directive also specifies the controller and dictates the setup of its scope.
The flowchart directive controls and coordinates the other components as shown in the following diagram:
The flowchart directive and controller are defined in flowchart_directive.js under the flowchart directory. The first line defines the AngularJS module:
angular.module('flowChart', ['dragging'] )
The module depends on the dragging module, which provides mouse handling services.
This module actually contains two AngularJS directives:
.directive('flowChart', function() { // ... }) .directive('chartJsonEdit', function () { // ... })
The flowChart directive specifies the SVG template and the flowchart controller. We will look at this in detail in the next section.
The chartJsonEdit directive is a helper that allows us to see and edit the flowchart's JSON representation alongside the visual SVG representation. This is mostly for testing, debugging and helping understand how the flowchart works, you probably wont't use this in production, but I have left it in as it provides a good example of how two views can display the same view-model, we'll look into this in more detail later.
After the two directives, the flowchart controller takes up the majority of this file:
.controller('FlowChartController', ['$scope', 'dragging', '$element', function FlowChartController ($scope, dragging, $element) { // ... } ]) ;
In the coming sections we will cover each of the flowchart components in detail.
Components Overview
Flowchart Directive
Using a directive to implement the flowchart is essentially making it into a reusable control. The entire directive is small and self-contained:
.directive('flowChart', function() { return { restrict: 'E', templateUrl: "flowchart/flowchart_template.html", replace: true, scope: { chart: "=chart", }, controller: 'FlowChartController', }; })
The directive is restricted to use as a HTML element:
restrict: 'E'
This effectively creates a new HTML element, such is the power of AngularJS, you can extend HTML with your own elements and attributes. There are other codes that can be applied here, for example, restricting to use as a HTML attribute (effectively creating a new HTML attribute):
restrict: 'A'
The next two lines specify the flowchart's template and that it should replace the flow-chart element:
templateUrl: "flowchart/flowchart_template.html", replace: true,
This causes the template to be injected into the DOM in place of the flowchart element:
Next, an isolated scope is setup:
scope: { chart: "=chart", },
This has the effect of creating a new child scope for the directive that is independent of the application's scope. Normally, creation of a new scope (say by a sub-controller) results in a child scope being nested under the parent scope. The child scope is linked to the parent via the prototypal inheritance chain, therefore the fields and functions of the parent are avaible via the child and may even be overridden by the child. An isolated scope breaks this connection, which is important for a reusable control like the flowchart as we don't want the two scopes interfering with each other.
Note the line:
chart: "=chart",
This causes the chart attribute of the HTML element to be data-bound to the chart variable in the scope. In this way we connect the chart's view-model from the application scope to the flowchart scope in a declarative manner.
The last part of the directive links it to the controller:
controller: "FlowChartController",
AngularJS creates the controller by name when the directive is instantiated.
Most examples of directives you see in the wild have a link function. In this case I use a controller instead of a link function to contain the directive's UI logic, I'll soon explain why.
JSON Editing Directive
The other directive defined in the same file is chartJsonEdit, which displays the flowchart's data-model as editable JSON text. This is really just a helper and not a crucial flowchart component. I use it for debugging and testing and it can also be useful to understand how things work generally. I include it here mainly because it is interesting to see how two separate views (if we consider the directives as views) can display the same view-model and stay synchronized.
I'll include the full code and comments here. The main thing to note is how the syncrhonization is achieved. $watch watches for a change in the data-model. Whenever a change is detected the data-model is seralized to JSON and displayed in the textbox. Whenever the user updates the textbox an event is invoked and the view-model is rebuilt from the updated data. A $digest is invoked manually so that AngularJS responds to the updated view-model..directive('chartJsonEdit', function () { return { restrict: 'A', scope: { viewModel: "=" }, link: function (scope, elem, attr) { // // Serialize the data model as json and update the textarea. // var updateJson = function () { if (scope.viewModel) { var json = JSON.stringify(scope.viewModel.data, null, 4); $(elem).val(json); } }; // // First up, set the initial value of the textarea. // updateJson(); // // Watch for changes in the data model and update the textarea whenever necessary. // scope.$watch("viewModel.data", updateJson, true); // // Handle the change event from the textarea and update the data model // from the modified json. // $(elem).bind("input propertychange", function () { var json = $(elem).val(); var dataModel = JSON.parse(json); scope.viewModel = new flowchart.ChartViewModel(dataModel); scope.$digest(); }); } }; })
Flowchart Controller
The purpose of the controller is to provide the input event handlers that are bound to the DOM by the template. Event handling is then generally routed to the view-model. As the UI logic is delegated to the view-model, the controller's job is simply to translate input events into view-model operations. This job could have easily been done by the directive's link function, however separating the UI logic out to the controller has made it much easer to unit-test as the controller can be instantiated without a DOM.
The controller is registered in flowchart_directive.js after the two directives and takes up most of the file. The controller itself is a Javascript constructor function registered via the flowchart module's controller function:
.controller('FlowChartController', ['$scope', 'dragging', '$element', function FlowChartController ($scope, dragging, $element) { // ... } ]) ;
The controller is registered with the name FlowChartController, which is the name used to reference the controller from the directive:
The controller parameters are automaticatically created and dependency injected by AngularJS when the controller is instantiated. As we saw with the application controller the names of the parameters are specified twice. If we didn't need minification we could get by with the names only specified once, as the names of the parameters themselves.
$scope is the directive's isolated scope, containing a chart field that is the view-model that has been transferred over from the application's scope.
dragging is a custom service that helps with mouse handling, which is so interesting it gets its own section.
$element is the HTML element that the controller is attached to. This parameter is easily mocked for unit-testing, which allows testing of the controller without actually instantiating the DOM.
In the first line of the controller we cache the this variable as a local variable named controller:
var controller = this;
This is the same as Javascript's usual var that = this idiom and is required so that the this variable, i.e. the flowchart controller, can be accessed from anonymous callback functions.
Next we cache a reference to the document and jQuery:
this.document = document; this.jQuery = function (element) { return $(element); }
This enables unit-testing as document and jQuery are easily replaced by mock objects.
Next we setup the scope variables, followed by a number of the controller's functions. Then event handlers, such as mouseDown, are assigned to the scope to be referenced from the template.
That's all the detail on the controller for now, there is still a lot to cover here and we'll deal with it piece by piece in coming sections.
Flowchart Template
The template defines the SVG that makes up the flowchart visuals. It is entirely self-contained with no sub-templates. Sub-templates are of course possible with AngularJS (and usually desirable), but they can cause problems with SVG. The template generates the UI from the view-model and determines how DOM events are bound to functions in the scope.
The template can be found in flowchart_template.html. After understanding the flowchart directive we know that the template's content completely replaces the flow-chart element in index.html.
The entire template is wrapped in a single root SVG element:
<svg class="draggable-container" xmlns="http://www.w3.org/2000/svg" ng-mousedown="mouseDown($event)" ng-mousemove="mouseMove($event)" > <defs> <!-- ... --> </defs> <!-- ... content ... --> </svg>
Mouse handling is performed at multiple levels in the DOM. Mouse down and mouse move are handled on the SVG element to implement drag selection and mouse over. Other examples of mouse handling can be found through-out the template as it underpins multiple features, such as: selection of nodes and connections, dragging of nodes and dragging of connections.
The defs element defines a single reusable SVG linearGradient that is used to fill the background of the nodes. The remainder of the template is the content that displays the nodes, connectors and connections. Near the end of the template graphics are defined for the dragging connection (the connection the user is dragging out) and the drag selection rectangle.
Flowchart View-Model
The view-model closely wraps the data-model and represents it to the view. It provides UI logic and coordinates operations on the data. The view-model can be found in flowchart_viewmodel.js.
So really, why have a view-model at all?
It's true that all the flowchart code could live in the flowchart controller, or even in the flowchart directive. We already know that the flowchart controller is separate for ease of unit-testing. Separating the view-model also helps unit-testing, as well as improving modularity and simplifying the code. However, the primary reason for separation of the view-model is that it allows the application code to interact directly with the view-model, which is much more convenient than interacting with the directive or controller. Simply put, the application owns the view-model which it passes to the directive/controller. The application is then free to directly manipulate the view-model and the application code doesn't interface at all with the directive or controller.
flowchart_viewmodel.js contains multiple Javascript classes that comprise the view-model: ConnectorViewModel, NodeViewModel, ConnectionViewModel and ChartViewModel. To be sure this file is borderline too large! If much more code were added I'd refactor and split it out into a separate file for each component. All the view-model constructor functions are contained within the flowchart object which creates a namespace for the view-model code.All of the constructor functions take as a parameter (at least) the data-model to be wrapped-up. The data-model in the simplest case can be an empty object:
var chartDataModel = {}; var chartViewModel = new flowchart.ChartViewModel(chartDataModel);
When the data-model is empty, the view-model will flesh it out as necessary. A view-model can also be created from a fully or partially complete data-model, for example one that is AJAX'd as JSON:
var chartDataModel = { nodes: [ // ... ], connections: [ // ... ] }; var chartViewModel = new flowchart.ChartViewModel(chartDataModel);
View-models for each node are created in a similar way:
var nodeViewModel = new flowchart.NodeViewModel(nodeDataModel);
Connectors are a bit different, the x, y coordinates of the connector are computed and passed in, along with a reference to the view-model of the parent node:
var connectorViewModel = new flowchart.ConnectorViewModel(connectorDataModel, computedX, computedY, parentNodeViewModel);
Connections are different again and given references to the view-models for the source and dest connectors they are attached to:
var connectionViewModel = new flowchart.ConnectionViewModel(connectionDataModel, sourceConnectorViewModel, destConnectorViewModel);
The following diagram illustrates how the view-model wraps the data-model:
In summary, the flowchart view-model wraps up numerous functions for manipulating and presenting the flowchart. Including selection, drag selection, deleting nodes and connections and creating new connections.
Unit Tests
TDD and the unit-tests have kept this project alive and kicking from the start. The unit tests really came into their own and saved the day when it was time to make my code run on multiple browsers (arguably I should have been doing this from the beginning, but I'm pretty new to the cross-browser stuff).
As a standard unit-test files have the same name as the source file under test, but with .spec on the end. For example the unit-tests for flowchart_viewmodel.js are in flowchart_viewmodel.spec.js.
Jasmine is a fantastic testing framework. Along with the code I have included the Jasmine spec runner, the HTML page that runs the tests. It is under the jasmine directory. When you have the web server running you can point your browser at http://localhost:8888/jasmine/SpecRunner.html to run the unit-tests.
Graph Concepts
In this section I discuss each element of the flowchart and what is required to represent it in the UI.
Representing nodes
To render a collection of things, eg flowchart nodes, we use AngularJS's ng-repeat. Here it is used to render all of the nodes in the view-model:
<g ng-repeat="node in chart.nodes" ng-mousedown="nodeMouseDown($event, node)" ng-attr-transform="translate({{node.x()}}, {{node.y()}})" > <!-- ... node content ... --> </g>
ng-repeat causes the SVG g element to be expanded out and repeated once for each node. The repetition is driven by the array of nodes supplied by the view-model: chart.nodes. At each repetition a variable node is defined that references the view-model for the node.
ng-mousedown binds the mouse down event for nodes to the controller's nodeMouseDown which contains the logic to be invoked when the mouse is pressed on a node, the node itself is passed through as a parameter.
ng-attr-transform sets the SVG transform attribute to a translation that positions the node according to x, y coordinates from the view-model.
ng-attr-<attribute-name> is a new AngularJS feature that sets a given HTML or SVG attribute after evaluating an AngularJS expression. This feature is so new that there doesn't appear to be any documentation for it yet, although you will find a mention of it (specifically related to SVG) in the directive documentation. I'll talk more about the need for ng-attr- in the section Problems with SVG, meanwhile we will see it used throughout the template.
The background of each node is an SVG rect:
<rect ng-attr-class="{{node.selected() && 'selected-node-rect' || (node == mouseOverNode && 'mouseover-node-rect' || 'node-rect')}}" ry="10" rx="10" x="0" y="0" ng-attr-width="{{node.width()}}" ng-attr-height="{{node.height()}}" fill="url(#nodeBackgroundGradient)" > </rect>
ng-attr-class conditionally sets the SVG class depending on whether the node is selected, unselected or whether the mouse is hovered over the node. Other methods of setting SVG class (via jQuery/AngularJS), that normally work for HTML class, don't work so well as I will describe later.
ng-attr-width and -height set the width and height of the rect.
fill sets the fill of the rect to nodeBackgroundGradient which was defined early in the defs section of the SVG.
Next an SVG text displays the node's name:
<text ng-attr-x="{{node.width()/2}}" y="25" text-anchor="middle" alignment-baseline="middle" > {{node.name()}} </text>
The text is centered horizontally by anchoring it to the middle of the node. The example here of ng-attr-x really starts to show the power of AngularJS expressions. Here we are doing a computation within the expression to determine the horizontal center point of the node, the result of the expression sets the x coordinate of the text.
After the text we see two separate sections that display the node's input and output connectors. Before we look deeper into the visuals for connectors let's have an overview of how the rendered node relates to its SVG template.
The ng-repeat:
Node background and name:
Representing connectors
Input and output connectors are roughly the same and so I will only discuss input connectors and point out the differences.
Here again is a use of ng-repeat to generate multiple SVG elements:
<g ng-repeat="connector in node.inputConnectors" ng-mousedown="connectorMouseDown($event, node, connector, $index, true)" class="connector input-connector" > <!-- ... connector content ... --> </g>
This looks very similar to the SVG for a node having an ng-repeat and a handler for mouse down. This time a static class is applied to the SVG g element that defines it as both a connector and an input-connector. If it were an output connector it would instead have the output-connector class applied.
Each connector is made from two elements. The first is a text element to display the name:
<text ng-attr-x="{{connector.x() + 20}}" ng-attr-y="{{connector.y()}}" text-anchor="left" alignment-baseline="middle" > {{connector.name()}} </text>
The only difference between the input and output connectors is the expression assigned to the x coordinate. An input connector is on the left of the node and so it is offset slightly to the right. An output connector is on the opposite side and therefore it is offset to the left.
The second element is a circle shape that represents the connection anchor point, this is an SVG circle positioned at the connector's coordinates:
<circle ng-attr-class="{{connector == mouseOverConnector && 'mouseover-connector-circle' || 'connector-circle'}}" ng-attr-r="{{connectorSize}}" ng-attr-cx="{{connector.x()}}" ng-attr-cy="{{connector.y()}}" />
ng-attr-class is used to conditionally set the class of the connector depending on whether the mouse is hovered over it. The other attributes set the position and size of the circle.
The following diagram shows how the rendered connectors relate to the SVG template. First the ng-repeat:
And the content of each connector:
Representing connections
Connections are composed of a curved SVG path with SVG circles attached at each end. Multiple connections are displayed using the now familiar ng-repeat:
<g ng-repeat="connection in chart.connections" class="connection" ng-mousedown="connectionMouseDown($event, connection)" > <!-- ... connection content ... --> </g>
The coordinates for the curved path are computed by the view-model:
<path ng-attr-class="{{connection.selected() && 'selected-connection-line' || (connection == mouseOverConnection && 'mouseover-connection-line' || 'connection-line')}}" ng-attr-d="M {{connection.sourceCoordX()}}, {{connection.sourceCoordY()}} C {{connection.sourceTangentX()}}, {{connection.sourceTangentY()}} {{connection.destTangentX()}}, {{connection.destTangentY()}} {{connection.destCoordX()}}, {{connection.destCoordY()}}" > </path>
Each end of the connection is capped with a small filled circle. The source and dest -ends look much the same, so let's look at the source-end only:
<circle ng-attr-class="{{connection.selected() && 'selected-connection-endpoint' || (connection == mouseOverConnection && 'mouseover-connection-endpoint' || 'connection-endpoint')}}" r="5" ng-attr-cx="{{connection.sourceCoordX()}}" ng-attr-cy="{{connection.sourceCoordY()}}" > </circle>
Now some diagrams to understand the relationship between the rendered connections and the template.
The ng-repeat:
The content of a connection:
UI Features
In this section I will cover the implementation of a number of UI features. The discussion will cross-cut through application, directive, controller, view-model and template to examine the workings of each feature.
Selection
Nodes and connections can be in either the selected or unselected state. A single left-click selects a node or connection. A click on the background deselects all. Control + click enables multiple selection.
Supporting selection is a major reason for individually wrapping the data-models for nodes and connections in view-models. These view-models at their simplest have a _selected boolean field that stores the current selection state. This value must be stored in the view-model and not in the data-model, to do otherwise would unnecessarily pollute the data-model and make it less reusable with different types of views.
The view-models for nodes and connections, NodeViewModel and ConnectionViewModel, both have a simple API for managing selection consisting of:
- select() to select the node or connection;
- deselect() to deselect it;
- toggleSelected() to change selection based on current state; and
- selected() which returns true when currently selected.
ChartViewModel has a selection API for managing chart selection as a whole:
- selectAll() selects all nodes and connections in the chart;
- deselectAll() deselects everything;
- updateSelectedNodesLocation(...) offsets selected nodes by the specified delta;
- deleteSelected() deletes everything that is selected;
- applySelectionRect(...) selects everything that is contained within the specified rect; and
- getSelectedNodes() retrieves the list of nodes that are selected.
The visuals for nodes and connections are modified dynamically according to their selection state. ng-attr-class completely switches classes depending on the result of a call to selected(), for example, setting the class of a node:
<rect ng-attr-class="{{node.selected() && 'selected-node-rect' || (node == mouseOverNode && 'mouseover-node-rect' || 'node-rect')}}" ... > </rect>
Of course the expression is more complicated because we are also setting the class based on the mouse-over state. If you are new to Javacript I should note that the kind of expression used above acts like the ternary operator.
When node.selected() returns true the class of the SVG rect is set to selected-node-rect, a class defined in app.css, and modifies the node's visual to indicate that it is selected.
The same technique is also used to conditionally set the class of connections.
Drag Selection
Nodes and connections can also be selected by dragging out a selection rectangle to contain the items to be selected:
Drag selection is handled at multiple levels:
- The template binds mouse event handlers to the DOM;
- The controller provides the event handlers and coordinates the dragging; and
- The view-model determines which nodes to select and then selects them.
Ultimately, the final action during drag selection, is to select nodes and connections that are contained within the drag selection rect. The coordinates and size of the rect are passed to applySelectionRect. This function applies the selection in the following steps:
- Everything that is initially selected is deselected.
- Nodes are tested against the selection rect and those that are contained within it are selected.
- Connections are selected when they are attached to nodes selected in the previous pass.
The flowchart controller receives mouse events and coordinates the dragging operation. Mouse down is the event we are interested in here which is handled by mouseDown in the controller:
<svg class="draggable-container" xmlns="http://www.w3.org/2000/svg" ng-mousedown="mouseDown($event)" ng-mousemove="mouseMove($event)" > <!-- ... --> </svg>
Looking into mouseDown we see the first use of the dragging service. This is a custom service I have created to help manage dragging operations in AngularJS. Over the next few sections we'll see multiple examples of it and later we'll look at the implementation. The dragging service is dependency injected as the dragging parameter to the controller and this allows us to use the service anywhere within the controller.
The first thing to note about mouseDown is that it is attached to $scope and this makes it available for binding in the HTML:
$scope.mouseDown = function (evt) { // ... };
mouseDown's first task is to ensure nothing is selected. This means that any mouse down in the flowchart deselects everything. This is exactly the behavior we want when clicking in the background of the flowchart:
$scope.mouseDown = function (evt) { $scope.chart.deselectAll(); // ... };
After deselecting all, startDrag is called on the dragging service to commence the dragging operation:
$scope.mouseDown = function (evt) { // ... deselect all ... dragging.startDrag(evt, { // ... }); };
The dragging operation will continue until a mouse up is detected, in this case a mouse up on the root SVG element. Note though that we don't handle mouse up explicitly, it is handled automatically by the dragging service and it is the draggable-container class on the SVG element which identifies it as the element within which dragging will be contained.
Multiple event handlers (or callbacks) are passed as parameters and are invoked at key points in the dragging operation:
dragging.startDrag(evt, { dragStarted: function (x, y) { // ... }, dragging: function (x, y) { // ... }, dragEnded: function () { // ... }, });
- dragStarted is called when dragging has commenced;
- dragging is called repeatedly during dragging; and finally
- dragEnded is called when dragging has been ended by the user.
dragStarted sets up scope variables that track the state of the dragging operation:
dragging.startDrag(evt, { dragStarted: function (x, y) { $scope.dragSelecting = true; var startPoint = controller.translateCoordinates(x, y); $scope.dragSelectionStartPoint = startPoint; $scope.dragSelectionRect = { x: startPoint.x, y: startPoint.y, width: 0, height: 0, }; }, dragging: // ... dragEnded: // ... });
dragSelectionRect tracks the coordinates and size of the selection rectangle and is needed to visually display the selection rect.
dragging is invoked on each mouse movement during the dragging operation. It continuously updates dragSelectionRect as the rect is dragged by the user:
dragging.startDrag(evt, { dragStarted: // ... dragging: function (deltaX, deltaY, x, y) { var startPoint = $scope.dragSelectionStartPoint; var curPoint = controller.translateCoordinates(x, y); $scope.dragSelectionRect = { x: curPoint.x > startPoint.x ? startPoint.x : curPoint.x, y: curPoint.y > startPoint.y ? startPoint.y : curPoint.y, width: curPoint.x > startPoint.x ? x - startPoint.x : startPoint.x - x, height: curPoint.y > startPoint.y ? y - startPoint.y : startPoint.y - y, }; }, dragEnded: // ... });
Eventually the drag operation completes and dragEnded is invoked. This calls into the view-model to apply the selection rect and then deletes the scope variables that were used to track the selection rectangle:
dragging.startDrag(evt, { dragStarted: // ... dragging: // ... dragEnded: function () { $scope.dragSelecting = false; $scope.chart.applySelectionRect($scope.dragSelectionRect); delete $scope.dragSelectionStartPoint; delete $scope.dragSelectionRect; }, });
The selection rect itself is displayed as a simple SVG rect:
<rect ng-if="dragSelecting" class="drag-selection-rect" ng-attr-x="{{dragSelectionRect.x}}" ng-attr-y="{{dragSelectionRect.y}}" ng-attr-width="{{dragSelectionRect.width}}" ng-attr-height="{{dragSelectionRect.height}}" > </rect>
The rect only needs to be shown when the user is actually dragging, so it is conditionally enabled using an ng-if that is bound to the dragSelecting variable. If you look back at dragStarted and dragEnded you will see that this variable is set to true during the dragging operation.
The rect is positioned by the ng-attr- atttributes that set its coordinates and size:
Node Dragging
Nodes can be dragged by clicking anywhere on a node and dragging. Multiple selected nodes can be dragged at the same time.
Mouse down is handled for nodes and calls nodeMouseDown:
<g ng-repeat="node in chart.nodes" ng-mousedown="nodeMouseDown($event, node)" ng-attr-transform="translate({{node.x()}}, {{node.y()}})" > <! -- ... --> </g>
nodeMouseDown uses the dragging service to coordinate the dragging of nodes:
$scope.nodeMouseDown = function (evt, node) { // ... dragging.startDrag(evt, { dragStarted: // ... dragging: // ... clicked: // ... }); };
As we have already seen, a number of event handlers (or callbacks) are passed to startDrag which are invoked during the dragging operation.
dragStarted is invoked when dragging commences.
dragStarted: function (x, y) { lastMouseCoords = controller.translateCoordinates(x, y); if (!node.selected()) { chart.deselectAll(); node.select(); } },
When dragging a selected node all selected nodes are also dragged and the selection is not changed. However when dragging a node that is not already selected, only that node is selected and dragged.
dragging is invoked repeatedly during the dragging operation. It computes delta mouse coordinates and calls into the view-model to update the positions of the selected nodes.
dragging: function (x, y) { var curCoords = controller.translateCoordinates(x, y); var deltaX = curCoords.x - lastMouseCoords.x; var deltaY = curCoords.y - lastMouseCoords.y; chart.updateSelectedNodesLocation(deltaX, deltaY); lastMouseCoords = curCoords; },
updateSelectedNodesLocation is the view-model function that updates the positions of the nodes being dragged. It is trivial, simply enumerating selected nodes and directly updating their coordinates:
this.updateSelectedNodesLocation = function (deltaX, deltaY) { var selectedNodes = this.getSelectedNodes(); for (var i = 0; i < selectedNodes.length; ++i) { var node = selectedNodes[i]; node.data.x += deltaX; node.data.y += deltaY; } };
There is no need to handle dragEnded in this circumstance, so it is omitted and ignored by the dragging service.
The clicked callback is new, it is invoked when the mouse down results in a click rather than a drag operation. In this case we delegate to the view-model:
clicked: function () { chart.handleNodeClicked(node, evt.ctrlKey); },
handleNodeClicked either toggles the selection (when control is pressed) or deselects all and then only selects the clicked node:
this.handleNodeClicked = function (node, ctrlKey) { if (ctrlKey) { node.toggleSelected(); } else { this.deselectAll(); node.select(); } var nodeIndex = this.nodes.indexOf(node); if (nodeIndex == -1) { throw new Error("Failed to find node in view model!"); } this.nodes.splice(nodeIndex, 1); this.nodes.push(node); };
Notice the code at the end, it changes the order of nodes after each click. The node that was clicked is moved to the end of the list. As the list of nodes drives an ng-repeat, as seen earlier, it actually controls the render order of the nodes. This is usually known as Z order. This means that clicked nodes are always bought to the front.
Adding Nodes and Connectors
The UI for adding nodes to the flowchart is simple enough, I didn't spend much time on it. It is simply a button in index.html:
<button ng-click="addNewNode()" title="Add a new node to the chart" > Add Node </button>
The ng-click binds the click event to the addNewNode function. Clicking the button calls this function that is defined in app.js:
$scope.addNewNode = function () { var nodeName = prompt("Enter a node name:", "New node"); if (!nodeName) { return; } var newNodeDataModel = { // ... define node data-model ... }; $scope.chartViewModel.addNode(newNodeDataModel); };
The function first prompts the user to enter a name for the new node. This makes use of the prompt service which is defined in the same file and is an abstraction over the browser's prompt function. Next the data-model for the new node is setup, this is pretty much the same as the chart's initial data-model. Finally addNode is called to inject the new node into the chart's view-model.
Adding connectors is very similar to adding nodes. There are buttons for adding either an input or output connector. A function is called on button click, the user enters a name and a data-model is created before adding the connector to each selected node.
Deleting Nodes and Connections
Nodes and connections are deleted through the same mechanism. You select or multi-select what you want to delete then press the delete key or click the Delete Selected button. Clicking the button calls the deleteSelected function, which in turn calls through to the view-model:
$scope.deleteSelected = function () { $scope.chartViewModel.deleteSelected(); };
The delete key is handled for the body of the page using ng-keyup:
<body ... ng-keyup="keyUp($event)" > <!-- ... --> </body>
keyUp is called whenever a key is pressed, it checks the keycode for the delete key and it calls through to the view-model:
$scope.keyUp = function (evt) { if (evt.keyCode === deleteKeyCode) { // // Delete key. // $scope.chartViewModel.deleteSelected(); } // .... };
This method of key event handling seems a bit ugly to me. I'm aware that AngularJS plugins exist to bind hotkeys directly to scope functions, but I didn't want to include any extra dependencies in this project. If anyone knows a cleaner way of setting this up in AngularJS please let me know and I'll update the article!
When the view-model's deleteSelected is called it follows a few simple rules to determine which nodes and connectors to delete and which ones to keep, as illustrated in the following diagram:
deleteSelected has three main parts:
- Enumerate nodes and remove any that are selected.
- Enumerate connections and remove any that are selected or any for which an attached node has already been removed.
- Update the view- and data-model to contain only the nodes and connections to be kept.
The first part:
this.deleteSelected = function () { var newNodeViewModels = []; var newNodeDataModels = []; var deletedNodeIds = []; for (var nodeIndex = 0; nodeIndex < this.nodes.length; ++nodeIndex) { var node = this.nodes[nodeIndex]; if (!node.selected()) { // Only retain non-selected nodes. newNodeViewModels.push(node); newNodeDataModels.push(node.data); } else { // Keep track of nodes that were deleted, so their connections can also // be deleted. deletedNodeIds.push(node.data.id); } } // ... };
This code builds a new list that contains the nodes to be kept. Nodes that are not selected are added to this list. A separate list is built that contains the ids of nodes to be deleted. We hang onto the ids of deleted nodes in order to check which connections are now defunct because an attached node has been deleted.
And the second part:
this.deleteSelected = function () { var newNodeViewModels = []; var newNodeDataModels = []; var deletedNodeIds = []; // ... delete nodes ... var newConnectionViewModels = []; var newConnectionDataModels = []; for (var connectionIndex = 0; connectionIndex < this.connections.length; ++connectionIndex) { var connection = this.connections[connectionIndex]; if (!connection.selected() && deletedNodeIds.indexOf(connection.data.source.nodeID) === -1 && deletedNodeIds.indexOf(connection.data.dest.nodeID) === -1) { // // The nodes this connection is attached to, where not deleted, // so keep the connection. // newConnectionViewModels.push(connection); newConnectionDataModels.push(connection.data); } } // ... };
The code for deleting connections is similar to that for deleting nodes. Again we build a list of connections to be kept. In this case we are deleting connections not only when they are selected, but also when the attached node was just deleted.
The third part is the simplest, it updates the view-model and the data-model from the lists that were just built:
this.deleteSelected = function () { // ... delete nodes ... // ... delete connections ... this.nodes = newNodeViewModels; this.data.nodes = newNodeDataModels; this.connections = newConnectionViewModels; this.data.connections = newConnectionDataModels; };
Mouse Over and SVG Hit Testing
I have implemented mouse over support so that items in the flowchart can be highlighted when the mouse is hovered over them. It is interesting to look at this in more detail as I was unable to achieve it using AngularJS's event handling (eg ng-mouseenter and ng-mouseleave). Instead I had to implement SVG hit-test manually in order to determine the element that is under the mouse cursor.
The mouse-over feature isn't just cosmetic, it is necessary for connection dragging to know which connector a new connection is being dropped on.
The root SVG element binds ng-mousemove to the mouseMove function:
<svg ... ng-mousemove="mouseMove($event)" > <!-- ... --> </svg>
This enables mouse movement tracking for the entire SVG canvas.
mouseMove first clears the mouse over elements that might have been cached in the previous invocation:
$scope.mouseMove = function (evt) { $scope.mouseOverConnection = null; $scope.mouseOverConnector = null; $scope.mouseOverNode = null; // ... };
Next is the actual hit-test:
$scope.mouseMove = function (evt) { // ... clear cached elements ... var mouseOverElement = controller.hitTest(evt.clientX, evt.clientY); if (mouseOverElement == null) { // Mouse isn't over anything, just clear all. return; } // ... };
Hit-testing is invoked after each mouse movement to determine the SVG element currently under the mouse cursor. When no SVG element is under the mouse, because nothing was hit, mouseMove returns straight away because it has nothing more to do. When this happens the cached elements have already been cleared so the current state of the controller records that nothing was hit.
Next, various checks are made to determine what kind of element was clicked, so that the element (if it turns out to be a connection, connector or node) can be cached in the appropriate variable. Checking for connection mouse over is necessary only when connection dragging is not currently in progress. Therefore connection hit-testing must be conditionally enabled:
$scope.mouseMove = function (evt) { // ... if (!$scope.draggingConnection) { // Only allow 'connection mouse over' when not dragging out a connection. // Figure out if the mouse is over a connection. var scope = controller.checkForHit(mouseOverElement, controller.connectionClass); $scope.mouseOverConnection = (scope && scope.connection) ? scope.connection : null; if ($scope.mouseOverConnection) { // Don't attempt to mouse over anything else. return; } } // ... };
After connection hit-testing is connector hit-testing, followed by node hit-testing:
$scope.mouseMove = function (evt) { // ... // Figure out if the mouse is over a connector. var scope = controller.checkForHit(mouseOverElement, controller.connectorClass); $scope.mouseOverConnector = (scope && scope.connector) ? scope.connector : null; if ($scope.mouseOverConnector) { // Don't attempt to mouse over anything else. return; } // Figure out if the mouse is over a node. var scope = controller.checkForHit(mouseOverElement, controller.nodeClass); $scope.mouseOverNode = (scope && scope.node) ? scope.node : null; };
The mouse over element is cached in one of three variables: mouseOverConnection, mouseOverConnector or mouseOverNode. Each of these are scope variables and referenced from the SVG to conditionally enable a special class on mouse over to make the connection, connector or node look different when the mouse is hovered over it.
ng-attr-class conditionally sets the class of the SVG element depending on the mouse-over state (and also the selection-state):
ng-attr-class="{{connection.selected() && 'selected-connection-line' || (connection == mouseOverConnection && 'mouseover-connection-line' || 'connection-line')}}"
This convoluted expression sets the class to selected-connection-line when the connection is selected, to mouseover-connection-line when the mouse is hovered over it or to connection-line when neither of these conditions is true.
mouseMove relies on the functions hitTest and checkForHit to do its dirty work. hitTest simply calls elementFromPoint to determine the element under the specified coordinates:
this.hitTest = function (clientX, clientY) { return this.document.elementFromPoint(clientX, clientY); };
checkForHit invokes searchUp which recursively searches up the DOM for the element that has one of the following classes: connection, connector or node. In this way we can find the SVG element that relates most directly to the flowchart component we are hit-testing against.
this.searchUp = function (element, parentClass) { // // Reached the root. // if (element == null || element.length == 0) { return null; } // // Check if the element has the class that identifies it as a connector. // if (hasClassSVG(element, parentClass)) { // // Found the connector element. // return element; } // // Recursively search parent elements. // return this.searchUp(element.parent(), parentClass); };
searchUp relies on the custom function hasClassSVG to check the class of the element. jQuery would normally be used to check the class of a HTML element, but unfortunately it doesn't work correctly for SVG elements. I discuss this more in Problems with SVG.
Both hitTest and checkForHit are implemented as separate functions so they are easily replaced with mocks in the unit-tests.
Connection Dragging
Connections are created by dragging out a connector, creating a connection that can be dragged about by the user. Creation of the new connection is completed when its end-point has been dragged over to another connector and it is committed to the view-model. When a connection is being dragged it is represented by an SVG visual that is separate to the other connections in the flowchart.
ng-if conditionally displays the visual when draggingConnection is set to true:
<g ng-if="draggingConnection" > <path class="dragging-connection dragging-connection-line" ng-attr-d="M {{dragPoint1.x}}, {{dragPoint1.y}} C {{dragTangent1.x}}, {{dragTangent1.y}} {{dragTangent2.x}}, {{dragTangent2.y}} {{dragPoint2.x}}, {{dragPoint2.y}}" > </path> <circle class="dragging-connection dragging-connection-endpoint" r="4" ng-attr-cx="{{dragPoint1.x}}" ng-attr-cy="{{dragPoint1.y}}" > </circle> <circle class="dragging-connection dragging-connection-endpoint" r="4" ng-attr-cx="{{dragPoint2.x}}" ng-attr-cy="{{dragPoint2.y}}" > </circle> </g>
The end-points and curve of the connection are defined by the following variables: dragPoint1, dragPoint2, dragTangent1 and dragTangent2.
Connection dragging is initiated by a mouse down on a connector. The mouse down event is bound to connectorMouseDown:
<g ng-repeat="connector in node.outputConnectors" ng-mousedown="connectorMouseDown($event, node, connector, $index, false)" class="connector output-connector" > <!-- ... connector ... --> </g>
connectorMouseDown uses the dragging service to manage the dragging operation, something we now seen multiple times:
$scope.connectorMouseDown = function (evt, node, connector, connectorIndex, isInputConnector) { dragging.startDrag(evt, { // ... handle dragging events ... }); };
The end-points and tangents are computed when dragging commences:
dragStarted: function (x, y) { var curCoords = controller.translateCoordinates(x, y); $scope.draggingConnection = true; $scope.dragPoint1 = flowchart.computeConnectorPos(node, connectorIndex, isInputConnector); $scope.dragPoint2 = { x: curCoords.x, y: curCoords.y }; $scope.dragTangent1 = flowchart.computeConnectionSourceTangent($scope.dragPoint1, $scope.dragPoint2); $scope.dragTangent2 = flowchart.computeConnectionDestTangent($scope.dragPoint1, $scope.dragPoint2); },
draggingConnection has been set to true enabling display of the SVG visual.
The first end-point is anchored to the connector that was dragged out. The second end-point is anchored to the current position of the mouse cursor.
The connection's end-points and tangents are updated repeatedly during dragging:
dragging: function (x, y, evt) { var startCoords = controller.translateCoordinates(x, y); $scope.dragPoint1 = flowchart.computeConnectorPos(node, connectorIndex, isInputConnector); $scope.dragPoint2 = { x: startCoords.x, y: startCoords.y }; $scope.dragTangent1 = flowchart.computeConnectionSourceTangent($scope.dragPoint1, $scope.dragPoint2); $scope.dragTangent2 = flowchart.computeConnectionDestTangent($scope.dragPoint1, $scope.dragPoint2); },
Upon completion of the drag operation the new connection is committed to the flowchart:
dragEnded: function () { if ($scope.mouseOverConnector && $scope.mouseOverConnector !== connector) { $scope.chart.createNewConnection(connector, $scope.mouseOverConnector); } $scope.draggingConnection = false; delete $scope.dragPoint1; delete $scope.dragTangent1; delete $scope.dragPoint2; delete $scope.dragTangent2; },
The scope variables that are no longer needed are deleted. draggingConnection is then set to false to disable rendering of the dragging connection visual.
Note the single validation rule: A connection cannot be created that loops back to the same connector. If this were production code it would likely have more validation rules or some way of adding user-defined rules.
If you are interested in the call to translateCoordinates, I'll explain that in Problems with SVG.
Dragging Service
The flowchart relies on good handling of mouse input, so it was really important to get that right. It is only the flowchart directive that talks to the dragging service, the view-model has no knowledge of it. The dragging service in turn depends on the mouse capture service.
Dragging is necessary in many different applications and it is surprisingly tricky to get right. Dragging code directly embedded in UI code complicates things because you generally have to manage the dragging operation as some kind of state machine. This can become more painful as different types of dragging operations are required and complexity grows.There are Javascript libraries and plugins that already do this kind of thing, however I wanted to make something that worked well with HTML, SVG and AngularJS.
The flowchart directive makes use of the dragging directive in the following ways and we have already examined how these work:
- Drag selection;
- Node dragging; and
- Connection dragging
You can start to imagine, if the dragging wasn't in a separate reusable library, how the flowchart directive (though relatively simple) could get very complicated, having all three dragging operations handled directly. Event driven programming comes to our rescue and Javascript has particular good support for this with its anonymous functions that we use to define inline callbacks for events.
startDrag must be called to initate the dragging operation. This is intended to be called in response to a mouse down event. Anonymous functions to handle the dragging events are passed as parameters:
dragging.startDrag(evt, { dragStarted: function (x, y) { // ... event handler ... }, dragging: function (x, y, evt) { // ... event handler ... }, dragEnded: function () { // ... event handler ... }, clicked: function () { // ... event handler ... }, });
dragStarted, dragging and dragEnded are invoked for key events during the dragging operation. clicked is invoked when a mouse down is followed by a mouse up but no dragging has occurred (or at least the mouse has not moved beyond a small threshold). This is considered to be a mouse click rather than a mouse drag.
The implementation of the service is in dragging_service.js. An AngularJS module is defined at the start:
angular.module('dragging', ['mouseCapture', ] )
The dragging module depends on the mouseCapture module. The rest of the file contains the definition of the service:
.factory('dragging', function ($rootScope, mouseCapture) { // ... return { // ... functions exported by the service ... }; }) ;
The object returned by the factory function is the actual service. The service is registered under the name dragging so that AngularJS can instantiate the service when it needs to be dependency injected into the FlowChartController as the dragging parameter.
The service exports the single function startDrag which we have already used several times:
return { startDrag: function (evt, config) { // ... }, };
The parameters to startDrag are the event object for the mouse down event and a configuration object containing the event handlers. startDrag captures the mouse for the duration of the dragging operation. The nested functions handle mouse events during the capture so that it may monitor the state of the mouse:
startDrag: function (evt, config) { var dragging = false; var x = evt.pageX; var y = evt.pageY; var mouseMove = function (evt) { // ... handle mouse move events during dragging ... }; var released = function() { // ... handle release of mouse capture and end dragging ... }; var mouseUp = function (evt) { // ... handle mouse up and release the mouse capture ... }; mouseCapture.acquire(evt, { mouseMove: mouseMove, mouseUp: mouseUp, released: released,\ }); evt.stopPropagation(); evt.preventDefault(); },
Calling mouseCapture.acquire captures the mouse and the service subsequently handles mouse input events. This allows the dragging operation to be initiated for a sub-element of the page (via a mouse down on that element) with dragging then handled by events on a parent element (in this case the body element). In Windows programming mouse capture is supported by the operating system. When working within the browser however this must be implemented manually, so I created a custom mouse capture service which is discussed in the next section.
Note that startDrag stops propagation of the DOM event and prevents the default action, the dragging service provides custom input handling so we prevent the browser's default action.
Let's look at the mouse event handlers that are active during dragging. The mouse move handler has two personalities. Before dragging has started it continuously checks the mouse coordinates to see if they move beyond a small threshold. When that happens the dragging operation commences and dragStarted is called.
From then on dragging is in progress and the mouseMove continuously tracks the coordinates of the mouse and repeatedly calls the dragging function.
var mouseMove = function (evt) { if (!dragging) { if (evt.pageX - x > threshold || evt.pageY - y > threshold) { dragging = true; if (config.dragStarted) { config.dragStarted(x, y, evt); } if (config.dragging) { // First 'dragging' call to take into account that we have // already moved the mouse by a 'threshold' amount. config.dragging(evt.pageX, evt.pageY, evt); } } } else { if (config.dragging) { config.dragging(evt.pageX, evt.pageY, evt); } x = evt.pageX; y = evt.pageY; } };
The release handler is called when mouse capture has been released. This can happen in one of two ways. The mouse up handler has stopped the dragging operation and requested that the mouse be released. Alternatively if some other code has acquired the mouse capture which forces a release. release also has two personalities, if dragging was in progress it invokes dragEnded. If dragging never commenced, because the mouse never moved beyond the threshold, clicked is instead invoked to indicate that dragging never started and the user simply mouse-clicked.
var released = function() { if (dragging) { if (config.dragEnded) { config.dragEnded(); } } else { if (config.clicked) { config.clicked(); } } };
The mouse up handler is simple, it just releases the mouse capture (which invokes the release handler) and stops propagation of the event.
var mouseUp = function (evt) { mouseCapture.release(); evt.stopPropagation(); evt.preventDefault(); };
Mouse Capture Service
Mouse capture is used as a matter of course when developing a Windows application. When mouse capture is acquired we are able to specially handle the mouse events for an element until the capture is released. When working in the browser there appears to be no built-in way to achieve this. Using an AngularJS directive and a service I was able to create my own custom attribute that attaches this behavior to the DOM.
The mouse-capture attribute identifies the element that can capture the mouse. In the flowchart application mouse-capture is applied to the body of the HTML page:
<body ng-app="app" ng-controller="AppCtrl" mouse-capture ng-keydown="keyDown($event)" ng-keyup="keyUp($event)" > <!-- ... --> </body>
The small directive that implements this attribute is at the end of mouse_capture_directive.js. The rest of the file implements the service that is used to acquire the mouse capture.
The file starts by registering the module:
angular.module('mouseCapture', [])
This module has no dependencies, hence the empty array.
Next the service is registered:
.factory('mouseCapture', function ($rootScope) { // ... setup and event handlers ... return { // ... functions exported by the service ... }; })
This is quite a big one and we'll come back to it in a moment. At the end of the file is a directive with the same name as the service:
.directive('mouseCapture', function () { return { restrict: 'A', controller: function($scope, $element, $attrs, mouseCapture) { mouseCapture.registerElement($element); }, }; }) ;
Both the service and the directive can have the same name because they are used in different contexts. The service is dependency injected into Javascript functions and the directive is used as a HTML attribute (hence the restrict: 'A'), so their usage does not overlap.
The directive defines a controller that it is initialized when the DOM is loaded. The mouseCapture service itself is injected into the controller along with the DOM element. The directive uses the service to register the element for mouse capture, this is the element for which mouse move and mouse up will be handled during the capture.
Going back to the service. The factory function defines several mouse event handlers before returning the service:
.factory('mouseCapture', function ($rootScope) { // ... state variables ... var mouseMove = function (evt) { // ... handle mouse movement while the mouse is captured ... }; var mouseUp = function (evt) { // ... handle mouse up while the mouse is capture ... }; return { // ... functions exported by the service ... }; })
The handlers are dynamically attached to the DOM when mouse capture is acquired and detached when mouse capture is released.
The service itself exports three functions:
return { registerElement: function(element) { // ... register the DOM element whose mouse events will be hooked ... }, acquire: function (evt, config) { // ... acquires the mouse capture ... }, release: function () { // ... releases the mouse capture ... }, };
registerElement is simple, it caches the single element whose mouse events can be captured (in this case the body element).
registerElement: function(element) { $element = element; },
acquire releases any previous mouse capture, caches the configuration object and binds the event handlers:
acquire: function (evt, config) { this.release(); mouseCaptureConfig = config; $element.mousemove(mouseMove); $element.mouseup(mouseUp); },
release invokes the released event handler and unbinds the event handlers:
release: function () { if (mouseCaptureConfig) { if (mouseCaptureConfig.released) { mouseCaptureConfig.released(); } mouseCaptureConfig = null; } $element.unbind("mousemove", mouseMove); $element.unbind("mouseup", mouseUp); },
While the mouse is captured mouseMove and mouseUp are invoked to handle mouse events, the events are relayed to higher-level code (such as the dragging service).
mouseMove are mouseUp are pretty similar, so let's just look at mouseMove:
var mouseMove = function (evt) { if (mouseCaptureConfig && mouseCaptureConfig.mouseMove) { mouseCaptureConfig.mouseMove(evt); $rootScope.$digest(); } };
The $digest function must be called to make AngularJS aware of data-model changes made by clients of the mouse capture service. AngularJS needs to know when the data-model has changed so that it can re-render the DOM as necessary. Most of the time when writing an AngularJS application you don't need to know about $digest, it only comes into play when you are working at a low-level in a directive or a service and usually working directly with the DOM.
Problems
Problems with Web UI
Client-side web development is fraught with problems and this is obvious to anyone who has been engaged in it. Using libraries such as jQuery and frameworks like AngularJS goes a long way to avoiding problems. Using Javascript appropriately (thanks Mr Crockford!) and having your code scaffolded by unit-tests goes even further to avoiding traditional issues. Good software development skills and an understanding of appropriate patterns help tremendously to avoid the Javascript maintenance and debugging nightmares of the past.
Even with all the problems associated with client-side web development I think I actually prefer it to regular application development. As a professional software developer I do a bit of both, but if possible in the future I may consider developing desktop applications as stand-alone web applications. The productivity boost associated with not having to use a compiler (unless you want to) and also the possibilities that arise from having a skinable application can't be overlooked, although I do miss Visual Studio's refactoring support.
One thing I really missed from Windows desktop programming was being able to capture the mouse and to achieve this I had create my own DIY mouse capture system.
Problems with AngularJS
Although I had a few issues with AngularJS, I want to be completely clear: AngularJS is awesome. It makes client-side web development so much easier to the point where it has pretty much convinced me that this is the better way to make UIs over and above WPF.
Since I first started this project AngularJS has evolved. Support for ng-attr- was added recently and appears be specifically to solve problems with data-binding attributes on SVG elements, exactly the problem I was having! This feature was so new and so necessary that originally I had to clone direct from the AngularJS repository to get early access to it. It is still so new that the only documentation they appear to have is part of the help for directives.
ng-if was another feature that came along during this project and being able to conditionally display HTML/SVG elements turned out to be very useful.
The learning curve was steep. This wasn't just AngularJS but leveling up my web development skills took considerable effort. All told though, there were very few problems with AngularJS and the amount of problems that it solves genuinely out-weighted its learning curve or any problems I had using it.
Problems with SVG
When I first started integrating AngularJS/jQuery and SVG I hit many small problems. To help figure out what I could and couldn't do, I made a massive test-bed that tested many different aspects of the integration. This allowed me to figure out the problem areas that I wanted to avoid, and find solutions for the areas that I couldn't avoid.
Creating the test-bed allowed me to work through the issues and improve my understanding of SVG and how it interacts with AngularJS features such as ng-repeat. I discovered that it was very difficult to create directives that inject SVG elements underneath the root SVG element. This appears to be due to jQuery creating elements in the HTML namespace rather than the SVG namespace. AngularJS uses jQuery under the hood so instantiating portions of SVG templates causes the elements not to be SVG elements at all, which clearly doesn't help. This is a well known problem when creating SVG elements with jQuery (if you guys are listening, please just fix it!) and there is a fair amount of information out there that will show you the hoops to jump through as a workaround. In the flowchart application though I was able to avoid the namespace problem completely by containing all my SVG under the one single template with the namespace explicitly specified in the SVG element.
Unfortunately the SVG DOM is different to the HTML DOM and so many jQuery functions that you might expect to work don't (although some do work fine). A notable example is with setting the class of an element. As this doesn't work for SVG when using jQuery, it doesn't work for AngularJS either, which builds on jQuery. So ng-class can't be used. This is why I have been forced to use ng-attr-class multiple times in the SVG for conditionally setting the class. This isn't such a bad option anyway as I think ng-attr-class is easier to understand than the alternatives, even though it does have the limitation of only being able to apply a single class to an element at a time. In other cases (eg, the mouse over code) I have worked around the class problem by avoiding jQuery and using custom functions for checking SVG class. Thanks to Justin McCandless for sharing his solution to this problem.
There are existing libraries that help deal with jQuery's bad SVG support. The jQuery SVG plugin looks good, but only if you want to create and manipulate SVG programatically. I was keen to define the SVG declaratively using an AngularJS template.
By implementing my own code for hit testing and mouse-over, I avoided potential problems with jQuery's mouseenter/mouseleave events relating to SVG. For this using the extremely simple function elementFromPoint seemed like the most convenient option.
Another jQuery problem I had was with the offset function. Originally I was using this to translate page coordinates into SVG coordinates. For some reason this didn't work properly under Firefox. After research online I created the translateCoordinates function that uses the SVG API to achieve the translation.
Another issue under Firefox was that the fill property for an SVG rect cannot be set using CSS. This worked in other browsers, but under Firefox I had to change it so fill had to be set as an attribute of the rect rather than via CSS.
I had one other problem that is worth mentioning. It was very odd and I never completely figured it out. I had nested SVG g elements representing a connection with ng-repeat applied to it to render multiple connections (that is a g nested within another g). When there were no connections (resulting in the ng-repeat displaying nothing) every SVG element after the connections was blown away. Just gone! The nested g element was actually redundant so I was able to cut it down to a single g containing the visuals for a connection. That fixed this very unusual problem. I tested out the problem under HTML instead of SVG and didn't get the issue, so I assume that it only manifests when using SVG under AngularJS (or possibly something to do with jQuery).
Conclusion
This is the end of the article. Thanks for taking the time to read it.
Any feedback or bug reports you give will be greatly appreciated and I'll endeavor to update the article and code as appropriate. I'll leave you with some ideas for the future and links to useful resources.
Future Improvements
The future improvements that could be applied to this code are simply the features that were in NetworkView from the original article:
- Templating to support different kind of nodes
- Adorners for feedback
- Zooming and panning
Resources
AngularJS:
jQuery:
SVG:
Jasmine:
http://pivotal.github.io/jasmine/
Test Driven Development:
http://net.tutsplus.com/tutorials/php/the-newbies-guide-to-test-driven-development/ </div>
<div class="float-right" style="margin:20px 0 0 10px;border:1px solid #ccc">
</div>