Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Proper Convention for Injecting D3 into AngualrJS

I've seen directives that just use the global D3 object, I've also seen directives that inject the global D3 object by just returning it in a service, and I've seen directives that add the D3 script and return a promise that is resolved on script load providing the D3 object.

Using it in an injectable service seems to make the most sense (see example 1 and 2), but I'm not sure which way is better. Example 2 would guarantee that D3 had been loaded before running any code on it, but it doesn't seem like anyone does this, plus it means you have to wrap your entire directive in the service otherwise d3 and the created svg object are out of scope or possibly undefined (see example 2), but at least the compile's promise I believe would always resolve first, see example 3.

Example 1: Service passing D3 global object

.factory('D3Service', [,
    function () {

        // Declare locals or other D3.js
        // specific configurations here.

        return d3;
    }]);

Example 2: Service adding D3 script to DOM and passing promise

.factory('D3Service', ['$window', '$document', '$q', '$rootScope',
    function ($window, $document, $q, $rootScope) {

        var defer = $q.defer();

        var scriptTag = $document[0].createElement('script');
        scriptTag.type = 'text/javascript';
        scriptTag.src = 'https://d3js.org/d3.v3.min.js';
        scriptTag.async = true;
        scriptTag.onreadystatechange = function () {

            if (this.readyState == 'complete') {
                onScriptLoad();
            }
        }
        scriptTag.onload = onScriptLoad;

        var script = $document[0].getElementsByTagName('body')[0];
        script.appendChild(scriptTag);

        //---
        // PUBLIC API
        //---

        return {
            d3: function () {
                return defer.promise;
            }
        };

        //---
        // PRIVATE METHODS.
        //---

        // Load D3 in the browser
        function onScriptLoad () {
            $rootScope.$apply(function () {
                defer.resolve($window.d3);
            });
        }
    }]);

Example 3: Using Compile adding SVG doesn't mean SVG is available in Link, but at least the compile's promise would always resolve first

        // Perform DOM and template manipulations
        function compile ($element, $attrs, $transclude) {

            var svg;

            // Callback provides raw D3 object
            D3Service.d3().then(function (d3) {

                // Create a responsive SVG root element
                svg = d3.select($element[0])
                .append('svg')
                .style('width', '100%');
            });

            // Return the link function
            return function($scope, $element, $attrs) {

                // Is svg undefined? 

                // Maybe? so have to wrap everything again in service
                D3Service.d3().then(function (d3) {

                   function render() {
                       // d3 and svg guaranteed to be available, but code gets really ugly looking and untestable
                   }
                });

                function render() {
                    // d3 and svg have to be passed in as they may not be available, but code is cleaner
                }
            };
        }
like image 540
mtpultz Avatar asked Dec 01 '15 00:12

mtpultz


1 Answers

I had similar questions when confronted with the issue of d3 and Angular. It seemed there were several ways to approach the problem; each were viable, but none felt smooth or natural. At its core, d3 and Angular just appear to be two very distinct technologies, and they don't play well together out-of-the-box. Don't get me wrong, they work together fantastically, but they need to warm up to each other. So at best, we can give d3 a playground within the Angular framework. And I believe this playground should be a directive.

But regarding the modular d3Service approach that returns a promise (per the loading of the d3.js file):

angular.module('myApp.directives', ['d3'])
  .directive('barChart', ['d3Service', function(d3Service) {
    return {
      link: function(scope, element, attrs) {
        d3Service.d3().then(function(d3) {
          // d3 is the raw d3 object
        });
      }}
  }]);

While this was detailed very well in the ngNewsletter, it just seems overkill to use a service that writes script tags directly to the DOM, when it could just be included in index.html with all the other javascript files. I mean, we have a directive that we know uses this file, so why not just load it purposely? No need to jump through hoops it seems, just:

<script src="/js/third-party/d3js/d3.min.js"></script>

However, this approach promise does provide modularity - let's say we're building multiple applications and each needed d3, then yes, being able to very easily inject our d3 module at the application level is great. But, you're always going to have to wait on that promise, even though we know it's going to resolve immediately after the initial load, you'll need to resolve it nonetheless. In any directive or controller that uses it. Always. Bummer.

So as I said, I opted to just include d3.js in my index.html, and as such I can just access it in my directives without needing to resolve a promise. Here's maybe a parallel: FWIW, I use JQuery promises over Angular promises, so what do I do when I need JQuery? Well, I just call it when I need it ($.Deferred()), my point being that invoking d3 in a similar fashion just didn't seem that egregious to me.

And while I do make use of a d3Service, its more for helper functions than anything else. For instance, when I want to obtain an SVG on which to do work, why not just call a function that gives me a responsive SVG:

Directive (link)

var svg = d3Service.getResponsiveCanvas(scope.id, margin, height, width);

Service

app.service('d3Service', function() {

  return {
      getResponsiveCanvas: function(id, margin, height, width) {
        return d3.select('#' + id)
              .append('div')
              .classed('svg-container', true)
              .append('svg')
              .attr('id', 'svg-' + id)
              .attr('preserveAspectRatio', 'xMinYMin meet')
              .attr('viewBox', '0 0 ' + (width + margin.left + margin.right) + ' ' + (height + margin.top + margin.bottom))
              .classed('svg-content-responsive', true)
              .append('g')
              .attr('transform', 'translate(' + margin.left + ', ' + margin.top + ')');
      }
  }
});

I have similar functions for adding axes to an SVG. And this does have a code smell, but again, by its very nature with d3 we're manipulating the DOM directly, so my experience is wherever we put this, its going to be ugly and not feeling very Angular-like, so you might as well craft some services that make your life easier.

like image 64
lux Avatar answered Sep 19 '22 07:09

lux