Contents
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:
http://localhost:8888/
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:
- Sublime2
- 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:
- Conemu for command line work
- NodeJS for running the simple local web server
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)
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) {
}])
;
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) {
})
;
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) {
}])
;
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) {
$scope.chartViewModel.deleteSelected();
}
};
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: [
],
connections: [
]
};
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.
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) {
var updateJson = function () {
if (scope.viewModel) {
var json = JSON.stringify(scope.viewModel.data, null, 4);
$(elem).val(json);
}
};
updateJson();
scope.$watch("viewModel.data", updateJson, true);
$(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) {
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 = {
};
$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) {
$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()) {
newNodeViewModels.push(node);
newNodeDataModels.push(node.data);
}
else {
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 = [];
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) {
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 () {
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) {
var mouseOverElement = controller.hitTest(evt.clientX, evt.clientY);
if (mouseOverElement == null) {
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) {
var scope = controller.checkForHit(mouseOverElement, controller.connectionClass);
$scope.mouseOverConnection = (scope && scope.connection) ? scope.connection : null;
if ($scope.mouseOverConnection) {
return;
}
}
};
After connection hit-testing is connector hit-testing, followed by node hit-testing:
$scope.mouseMove = function (evt) {
var scope = controller.checkForHit(mouseOverElement, controller.connectorClass);
$scope.mouseOverConnector = (scope && scope.connector) ? scope.connector : null;
if ($scope.mouseOverConnector) {
return;
}
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) {
if (element == null || element.length == 0) {
return null;
}
if (hasClassSVG(element, parentClass)) {
return element;
}
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, {
});
};
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.
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) {
},
dragging: function (x, y, evt) {
},
dragEnded: function () {
},
clicked: function () {
},
});
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 {
};
})
;
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) {
};
var released = function() {
};
var mouseUp = function (evt) {
};
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) {
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
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) {
return {
};
})
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) {
var mouseMove = function (evt) {
};
var mouseUp = function (evt) {
};
return {
};
})
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) {
},
acquire: function (evt, config) {
},
release: function () {
},
};
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).
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:
http://angularjs.org/
http://docs.angularjs.org/api
jQuery:
http://jquery.com/
SVG:
http://www.w3schools.com/svg/
Jasmine:
http://pivotal.github.io/jasmine/
Test Driven Development:
http://net.tutsplus.com/tutorials/php/the-newbies-guide-to-test-driven-development/