En la entrada anterior, encapsulé la gráfica realizada con d3.js de esta otra entrada dentro de un plugin jQuery, de modo que pudiéramos reutilizarla varias veces en la misma página. El objetivo de esta nueva entrada es volver a encapsular esta gráfica, pero esta vez usando Angular.js.
El resultado final
El resultado final es el siguiente:
Podéis abrir este mismo resultado en una ventana independiente aquí.
El resultado, efectivamente, es el mismo que el de la entrada anterior. Pero, eso sí, la diferencia de las implementaciones es tremenda.
El código html
El código html necesario para esta versión es el siguiente:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
<div class="container-fluid" ng-app="d3Angular" ng-controller="mainController as mainCtrl"> <div class="row"> <div jmj-graph id="graph1" class="col-lg-3 col-sm-4 graph" data-values="mainCtrl.sources[0].values" data-max-value="mainCtrl.sources[0].maxValue"> <p class="title">Graph 1</p> </div> <div jmj-graph id="graph2" class="col-lg-3 col-sm-4 graph" data-values="mainCtrl.sources[1].values" data-max-value="mainCtrl.sources[1].maxValue"> <p class="title">Graph 2</p> </div> <div jmj-graph id="graph3" class="col-lg-3 col-sm-4 graph" data-values="mainCtrl.sources[2].values" data-max-value="mainCtrl.sources[2].maxValue"> <p class="title">Graph 3</p> </div> <div jmj-graph id="graph4" class="col-lg-3 col-sm-4 graph" data-values="mainCtrl.sources[3].values" data-max-value="mainCtrl.sources[3].maxValue"> <p class="title">Graph 4</p> </div> <div jmj-graph id="graph5" class="col-lg-3 col-sm-4 graph" data-values="mainCtrl.sources[4].values" data-max-value="mainCtrl.sources[4].maxValue"> <p class="title">Graph 5</p> </div> <div jmj-graph id="graph6" class="col-lg-3 col-sm-4 graph" data-values="mainCtrl.sources[5].values" data-max-value="mainCtrl.sources[5].maxValue"> <p class="title">Graph 6</p> </div> </div> </div> |
Para centrarnos en las diferencias, comparémoslo con el código html que usamos para la versión jQuery:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
<div class="container-fluid"> <div class="row"> <div id="graph1" class="col-lg-3 col-sm-4 graph" data-max-value="20" data-time-interval="1000"> <p class="title">Graph 1</p> </div> <div id="graph2" class="col-lg-3 col-sm-4 graph" data-max-value="30" data-time-interval="2000"> <p class="title">Graph 2</p> </div> <div id="graph3" class="col-lg-3 col-sm-4 graph" data-max-value="40" data-time-interval="3000"> <p class="title">Graph 3</p> </div> <div id="graph4" class="col-lg-3 col-sm-4 graph" data-max-value="50" data-time-interval="1000"> <p class="title">Graph 4</p> </div> <div id="graph5" class="col-lg-3 col-sm-4 graph" data-max-value="60" data-time-interval="2000"> <p class="title">Graph 5</p> </div> <div id="graph6" class="col-lg-3 col-sm-4 graph" data-max-value="70" data-time-interval="3000"> <p class="title">Graph 6</p> </div> </div> </div> |
- Al contenedor principal, le he añadido dos nuevos atributos
ng-app
yng-controller
. - El atributo
ng-app
sirve para indicarle a Angular.js dónde comienza su alcance (su zona de influencia). - El atributo
ng-controller
sirve para asociar un componentecontroller
a un determinado elemento del documento. Más adelante, veremos la definición de estecontroller
y para qué sirve. Además, indicamos que a este controller lo vamos a identificar comomainCtrl
. - Seguimos teniendo seis elementos
div
, uno por cada gráfica. Aunque en esta nueva versión los construimos de forma diferente: - Todos tienen un nuevo atributo
jmj-graph
. Este atributo, es la marca que usamos para indicarle a Angular.js que a este elemento le vamos a aplicar el nuevo componente (el término correcto esdirective
) con la gráfica que vamos a encapsular y al que he llamadojmj-graph
. - Todos tienen dos nuevos atributos
data-values
ydata-max-value
. Estos dos atributos son la forma que tenemos en Angular.js de pasarle parámetros al nuevo componente (a cada gráfica, les pasamos la lista de valores y el valor máximo que queremos configurar). Fijaros que los valores que pasamos sonmainCtrl.xxx
(acordaros que en el punto anterior definimos uncontroller
identificado comomainCtrl
).
Hay expertos que defienden que el marcado html usado con Angular.js es más claro que el marcado html usado en la versión jQuery. El motivo que argumentan, es que la «versión Angular.js» es más parecida a una versión «basada en componentes» donde cada elemento html «sabe» qué tipo de componente es (en este caso, cada div
«sabe» que es un «componente gráfica») en contraposición a la «versión jQuery» en la que usamos ids y clases con nombres especiales para asociar el html y el javascript (¿qué ocurre si varíamos los ids o las clases en uno de los documentos, pero no en la pareja asociada?).
Podríamos incluso definir nuestro nuevo componente jmj-graph
para que podamos usarlo de esta otra manera:
1 2 3 |
<jmj-graph id="graph1" class="col-lg-3 col-sm-4 graph" data-max-value="20" data-time-interval="1000"> <p class="title">Graph 1</p> </jmj-graph> |
(O sea, prescindimos del elemento div
y usamos directamente un elemento jmj-graph
). El motivo de que no he usado esta variante es porque algunos navegadores no son compatibles con este tipo de marcado.
De hecho, usando AngularJS, podríamos comprimir todavía más nuestro código:
1 2 3 4 5 6 7 |
<div class="container-fluid" ng-app="d3Angular" ng-controller="mainController as mainCtrl"> <div class="row"> <div jmj-graph ng-repeat="value in mainCtrl.sources" id="graph{{$index}}" class="col-lg-3 col-sm-4 graph" data-values="value.values" data_max-value="value.maxValue"> <p class="title">Graph {{$index}}</p> </div> </div> </div> |
Lo que hemos hecho es que, como tenemos nuestros datos en un array mainCtrl.source
, Angular.js nos proporciona la directiva ng-repeat
para recorrer los elementos del array y crear estructuras html repetitivas (si no he aplicado esta variante en el código, es por mantener la claridad lo máximo posible).
El código javascript
Antes que nada, para tener una visión de conjunto, el código javascript al completo:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 |
'use strict'; (function(angular, d3, jQuery, DataSource) { var app = angular.module('d3Angular', []); var fact = function dataSourceFactory($rootScope) { return { getDataSource : function getDataSource(maxValue, timeInterval) { var dataSource = new DataSource({ maxValue : maxValue, timeInterval : timeInterval }); dataSource.onNewValue(function() { $rootScope.$digest(); }); return dataSource; } }; }; fact.$inject = ['$rootScope']; app.factory('dataSourceFactory', fact); var ctrl = function MainController(dataSourceFactory) { this.sources = []; var opts = [ { maxValue: 10, timeInterval: 1000 }, { maxValue: 20, timeInterval: 2000 }, { maxValue: 30, timeInterval: 3000 }, { maxValue: 40, timeInterval: 1000 }, { maxValue: 50, timeInterval: 2000 }, { maxValue: 60, timeInterval: 3000 } ]; for (var i=0; i<opts.length; i++) { var dataSource = dataSourceFactory.getDataSource(opts[i].maxValue, opts[i].timeInterval); this.sources.push({ values: dataSource.getData(), maxValue: opts[i].maxValue }); } }; ctrl.$inject = ['dataSourceFactory']; app.controller('mainController', ctrl); app.directive('jmjGraph', function () { var createGraph = function createGraph(scope, element) { var margins = { top: 10, right: 15, bottom: 20, left: 20 }; var svg = d3.selectAll(element).append('svg'); var width = parseInt(jQuery(element).css('width')) * 0.9; var height = Math.min(width, 600); svg.attr('width', width).attr('height', height); var xAxisLine = svg.append('svg:g') .attr('class', 'x axis') .attr('transform', 'translate(0,' + (height - margins.bottom) + ')'); var yAxisLine = svg.append('svg:g') .attr('class', 'y axis left') .attr('transform', 'translate(' + (margins.left) + ',0)'); var graphLine = svg.append('svg:path') .attr('class', 'graph-line'); var xRange = d3.time.scale() .range([margins.left, width - margins.right]); var yRange = d3.scale.linear() .range([height - margins.bottom, margins.top]) .domain([0,scope.maxValue]); var xAxis = d3.svg.axis() .scale(xRange) .tickFormat(d3.time.format('%H:%M')) .tickSize(5) .tickSubdivide(true); var yAxis = d3.svg.axis() .scale(yRange) .tickSize(5) .orient('left') .tickSubdivide(true); var valueFunc = d3.svg.line() .x(function(d) { return xRange(d.date); }) .y(function(d) { return yRange(d.value); }) .interpolate('linear'); var redrawData = function redrawData() { xRange.domain([d3.min(scope.values, function(d) { return d.date; }), d3.max(scope.values, function(d) { return d.date; })]); xAxisLine.transition().call(xAxis); yAxisLine.transition().call(yAxis); graphLine.attr('d', valueFunc(scope.values)); }; redrawData(); scope.$watch(function() { return scope.values; }, function() { redrawData(); }, true); }; return { restrict: 'A', transclude: true, template: '<ng-transclude></ng-transclude>', scope: { values : '=', maxValue : '=' }, link: createGraph }; }); })(window.angular, window.d3, window.jQuery, window.DataSource); |
Mucho me temo que para el lector acostumbrado a leer Javascript pero no entrenado a leer Angular.js, la diferencia entre leer esta versión y la versión jQuery es abismal. La versión jQuery es todavía lineal y se puede seguir el encapsulamiento de la gráfica (al menos, a mí me lo parece). Pero esta versión Angular.js resulta bastante dura de distinguir cómo se organiza el código. A ver si, poco a poco, logramos entenderlo (aunque sólo sea para tener una visión de conjunto).
Cómo está organizado el código
Antes de comenzar a recorrer el código paso a paso, me gustaría aclarar que Angular.js organiza el código en componentes que, a su vez, se organizan en módulos. Cualquier aplicación Angular.js necesita como mínimo un módulo. Y después se definen dentro de ese módulo (o en módulos adicionales) los componentes adicionales que hagan falta (si es que con los componentes estándares no hay suficiente).
Angular.js nos proporciona varios tipos de componentes: factory
, service
, provider
, controller
, directive
, constant
y value
(creo que no se me olvida ninguno). Cada uno de ellos, tiene sus propias funciones y mecanismos. Una de nuestras responsabilidades es elegir cómo vamos a organizar nuestro código Javascript (qué tipos de componentes vamos a usar). A veces, esta decisión es inmediata. Pero otras veces, las decisiones pueden ser más sutiles.
En nuestro caso, hemos definido un sólo módulo con tres componentes (un factory
, un controller
y un directive
). Estos tres componentes, podría haberlos definido en el orden que hubiera querido (más aún, en cualquier aplicación un poco seria tendríamos un fichero Javascript por componente). Pero los he definido en un orden concreto para intentar hacer la lectura del código más comprensible.
La definición del módulo principal
Nada más comenzar el código, tenemos la definición del módulo principal:
1 |
var app = angular.module('d3Angular', []); |
En este caso, nuestro módulo se llama d3Angular
y no depende de ningún otro módulo (el segundo argumento es []
). Además, obtenemos una referencia al módulo en la variable app
.
¿Recordáis nuestro código html, que aparecía el atributo ng-app="d3Angular"
? Efectivamente, de este modo enlazamos nuestro módulo recién definido al documento html (mejor dicho, no a todo el documento sino al elemento div
donde hemos definido el atributo, este detalle puede ser importante).
El componente factory
dataSourceFactory
A continuación, definimos dentro de nuestro nuevo módulo un componente de tipo factory
(fijaros que para realizar esta definición usamos el objeto app
que obtuvimos cuando definimos el módulo):
1 2 3 4 5 6 7 8 9 10 11 |
var fact = function dataSourceFactory($rootScope) { return { getDataSource : function getDataSource(maxValue, timeInterval) { var dataSource = new DataSource({ maxValue : maxValue, timeInterval : timeInterval }); dataSource.onNewValue(function() { $rootScope.$digest(); }); return dataSource; } }; }; fact.$inject = ['$rootScope']; app.factory('dataSourceFactory', fact); |
Angular.js admite hasta tres variantes para definir los componentes. Este mismo componente, también podría haberlo definido así:
1 2 3 4 5 6 7 8 9 |
app.factory('dataSourceFactory', function($rootScope) { return { getDataSource : function getDataSource(maxValue, timeInterval) { var dataSource = new DataSource({ maxValue : maxValue, timeInterval : timeInterval }); dataSource.onNewValue(function() { $rootScope.$digest(); }); return dataSource; } }; }); |
O así:
1 2 3 4 5 6 7 8 9 |
app.factory('dataSourceFactory', ['$rootScope', function($rootScope) { return { getDataSource : function getDataSource(maxValue, timeInterval) { var dataSource = new DataSource({ maxValue : maxValue, timeInterval : timeInterval }); dataSource.onNewValue(function() { $rootScope.$digest(); }); return dataSource; } }; }]); |
Cada forma tiene sus pequeñas sutilidades. La forma que yo he escogido, quizás sea la menos fácil de leer. Pero es la más recomendada.
¿Qué es lo que hemos hecho? Pues lo que hemos hecho es encapsular la clase DataSource
(que hemos usado para simular nuestra fuente de datos en nuestras tres versiones) dentro de un componente Angular.js. ¿Por qué he hecho esto? Porque, de este modo, garantizo que si un día cambio la fuente de datos, cambiando este componente conseguiré actualizar todo el código.
Angular.js nos ofrece varios tipos de componentes para encapsular servicios (factory
, service
y provider
). Para este ejemplo, yo he escogido el componente más simple (el factory
). Lo que hacemos es:
- En su definición, indicamos que dependemos de otro componente, que se llama
$rootScope
(por qué necesitamos esta dependencia, lo explicaré más adelante). - Devuelve un objeto con un sólo método
getDataSource
. Cuando se invoca a este método, crea un objeto de tipoDataSource
y registra una función anónima a su eventoonNewValue
que invoca al método$digest
del componente$rootScope
del que depende mi recién definidafactory
(paciencia, ya explicaré para qué necesito$rootScope
explicaré todo más adelante).
(Para comprender para qué sirve la clase DataSource
os recomiendo que leáis la primera entrada de esta pequeña serie).
El componente controller
mainController
Un componente de tipo controller
es como el pegamento que une todos los demás componentes. Es el coordinador de los demás componentes. ¿Qué quiero decir con esto?. Veamos el código de nuestro controller
, a ver si puedo explicarlo un poco mejor:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
var ctrl = function MainController(dataSourceFactory) { this.sources = []; var opts = [ { maxValue: 10, timeInterval: 1000 }, { maxValue: 20, timeInterval: 2000 }, { maxValue: 30, timeInterval: 3000 }, { maxValue: 40, timeInterval: 1000 }, { maxValue: 50, timeInterval: 2000 }, { maxValue: 60, timeInterval: 3000 } ]; for (var i=0; i<opts.length; i++) { var dataSource = dataSourceFactory.getDataSource(opts[i].maxValue, opts[i].timeInterval); this.sources.push({ values: dataSource.getData(), maxValue: opts[i].maxValue }); } }; ctrl.$inject = ['dataSourceFactory']; app.controller('mainController', ctrl); |
- En su definición, indicamos que depende del componente
dataSourceFactory
(el que acabamos de definir justo antes). - Define una propiedad
sources
que es un array. - Rellena este array con objetos de tipo
DataSource
(se los pide al componentedataSourceFactory
, para eso lo necesita).
Ahora, con esta definición leída, vamos a leer de nuevo nuestro código html:
1 2 3 4 5 |
<div class="container-fluid" ng-app="d3Angular" ng-controller="mainController as mainCtrl"> <div class="row"> <div jmj-graph id="graph1" class="col-lg-3 col-sm-4 graph" data-values="mainCtrl.sources[0].values" data-max-value="mainCtrl.sources[0].maxValue"> <p class="title">Graph 1</p> </div> |
Lo que nos está diciendo es: «quiero usar el controller
llamado mainController
y quiero asignarle el identificador mainCtrl
» y «quiero que el atributo data-values
valga la propiedad sources[0].values
de mi controller
mainCtrl
«.
Estamos usando nuestro controller
para llamar a nuestra factory
y para poder usar los valores extraídos (y que hemos asignado a una propiedad sources
) para pasárselos a nuestra directive
.
Por eso, digo que un controller
sirve para «pegar» y «coordinar» todos los componentes de nuestro aplicación Angular.js (lo mismo que le hemos definido a nuestro mainController
una propiedad, si hubiera hecho falta podríamos haber definido métodos para poder realizar desde nuestro html operaciones más complejas).
El componente directive
jmjGraph
Para definir nuevos componentes usables en mi código html, Angular.js nos proporciona el componente directive
. Veamos como defino mi componente jmj-graph
para que genere mi gráfica:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 |
app.directive('jmjGraph', function () { var createGraph = function createGraph(scope, element) { var margins = { top: 10, right: 15, bottom: 20, left: 20 }; var svg = d3.selectAll(element).append('svg'); var width = parseInt(jQuery(element).css('width')) * 0.9; var height = Math.min(width, 600); svg.attr('width', width).attr('height', height); var xAxisLine = svg.append('svg:g') .attr('class', 'x axis') .attr('transform', 'translate(0,' + (height - margins.bottom) + ')'); var yAxisLine = svg.append('svg:g') .attr('class', 'y axis left') .attr('transform', 'translate(' + (margins.left) + ',0)'); var graphLine = svg.append('svg:path') .attr('class', 'graph-line'); var xRange = d3.time.scale() .range([margins.left, width - margins.right]); var yRange = d3.scale.linear() .range([height - margins.bottom, margins.top]) .domain([0,scope.maxValue]); var xAxis = d3.svg.axis() .scale(xRange) .tickFormat(d3.time.format('%H:%M')) .tickSize(5) .tickSubdivide(true); var yAxis = d3.svg.axis() .scale(yRange) .tickSize(5) .orient('left') .tickSubdivide(true); var valueFunc = d3.svg.line() .x(function(d) { return xRange(d.date); }) .y(function(d) { return yRange(d.value); }) .interpolate('linear'); var redrawData = function redrawData() { xRange.domain([d3.min(scope.values, function(d) { return d.date; }), d3.max(scope.values, function(d) { return d.date; })]); xAxisLine.transition().call(xAxis); yAxisLine.transition().call(yAxis); graphLine.attr('d', valueFunc(scope.values)); }; redrawData(); scope.$watch(function() { return scope.values; }, function() { redrawData(); }, true); }; return { restrict: 'A', transclude: true, template: '<ng-transclude></ng-transclude>', scope: { values : '=', maxValue : '=' }, link: createGraph }; }); |
Este tipo de componentes, tiene muchísimos parámetros y muchas sutilidades que dominar. Desgraciadamente, es el precio que hay que pagar para tener un componente diseñado para cubrir cualquier escenario posible. A ver si puedo explicar las opciones que he usado yo para definir mi componente jmj-graph
.
Primero, me gustaría que nos concentráramos en la parte inicial y final del código:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
app.directive('jmjGraph', function () { var createGraph = function createGraph(scope, element) { // aquí, defino mi gráfica }; return { restrict: 'A', transclude: true, template: '<ng-transclude></ng-transclude>', scope: { values : '=', maxValue : '=' }, link: createGraph }; }); |
Vamos a leerlos juntos:
- Defino un componente
directive
de nombrejmjGraph
. Hasta la forma de escribir el nombre es importante: - Está escrito siguiendo la convención camelCase (
jmjGraph
). A la hora de usarlo en mi código html podré referirme a él comojmj-graph
(fijaros que se escribe todo en minúsculas y un guión ‘-‘ donde estaba la letra mayúscula). Pero también puedo referirme a él comodata-jmj-graph
ojmj:graph
ox:jmj:graph
. - Para evitar conflictos de nombre, hay que evitar los nombres de tipo «ngXxxxx» (para no coincidir con el nombre de alguna directiva AngularJS). En mi caso, he elegido «jmjXxxx».
- Defino una función interna
createGraph
que es la encargada de crear la gráfica (la explicaré un poco más adelante). - Devuelvo un objeto con una serie de opciones (que son las que realmente definen mi nuevo componente):
retrict
.transclude
.template
.scope
.link
.
Cada una de estas opciones, necesita su propia explicación (paciencia…).
La opción retrict
Esta opción sirve para definir cómo queremos que nuestro nuevo componente pueda ser usado dentro de nuestro código html. En nuestro caso, he elegido que pueda ser usada como un atributo ("A"
) pero también podía haber elegido que se podía usar como un elemento ("E"
) o como ambos ("AE"
), entre otras opciones.
La opción transclude
Esta opción es sutil. Por defecto, una directive
se «apodera» del contenido del elemento donde ha sido definida y borra el contenido existente (en nuestro caso, el contenido preexistente eran los elementos p
con los títulos de las gráficas). Para evitar este comportamiento, hay que usar esta opción transclude
con valor true
.
La opción template
Esta opción es muy potente. Te permite indicar el nuevo contenido que queremos generar cuando usemos el componente que estamos definiendo. Podemos escribir una plantilla con los elementos html que queramos. Incluso podemos incluir otras directivas dentro de la plantilla. Como queremos preservar el contenido existente, usamos dentro de la plantilla la directiva ng-transclude
(que representa a ese contenido preexistente) e indicamos dónde la queremos dentro del nuevo contenido.
Si la nueva plantilla fuera especialmente compleja o el proyecto fuera más grande, podríamos definir las plantillas en otro lugar y referenciarlas aquí mediante un enlace.
«Pero, espera, se supone que el nuevo contenido iba a ser una gráfica ¿dónde está ese nuevo contenido en la plantilla?» La plantilla, la uso sólo para indicar dónde quiero incluir mi contenido preexistente (usando la directiva ng-transclude
). El problema para incluir la gráfica en esta plantilla es que la gráfica es un objeto complejo y por lo tanto la plantilla no me sirve. Para incluir la gráfica, usaré la opción link
.
La opción scope
Esta opción es muy compleja. Este parámetro, sirve para configurar un elemento fundamental de la directiva: su scope
. Para explicar todo lo que sé sobre las scopes
, necesitaría una entrada exclusiva (como mínimo). Para esta entrada, me limito a indicar que me permite definir cuáles son los parámetros de entrada que espera mi directive
. En nuestro ejemplo:
- Un parámetro llamado
values
. - Otro parámetro llamado
maxValue
.
¿Os acordáis de nuestro código html? Lo copio de nuevo:
1 2 3 |
<div jmj-graph id="graph1" class="col-lg-3 col-sm-4 graph" data-values="mainCtrl.sources[0].values" <b>data-max-value</b>="mainCtrl.sources[0].maxValue"> <p class="title">Graph 1</p> </div> |
Efectivamente, uso los atributos data-values
y data-max-value
para pasarle a mi directive
los dos parámetros que espera recibir.
¿Y qué quiere decir ese '='
que he usado cuando he definido los parámetros?. Bueno, eso tiene su propia complejidad que está más allá del alcance de esta entrada.
La opción link
La opción link
sirve para definir una función que será llamada en tiempo de ejecución, cuando se genere el componente. Esta función, cuando sea llamada, tendrá suficiente información como para que generemos la gráfica dentro de ella. Por eso, le hemos pasado a esta opción que llame a nuestra función createGraph
.
La función createGraph
Como acabo de explicar antes, esta función será invocada en tiempo de ejecución, justo cuando el componente se esté generando (como tenemos seis instancias del componente, será invocada seis veces, una por componente). Vamos a estudiar esta función:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 |
var createGraph = function createGraph(scope, element) { var margins = { top: 10, right: 15, bottom: 20, left: 20 }; var svg = d3.selectAll(element).append('svg'); var width = parseInt(jQuery(element).css('width')) * 0.9; var height = Math.min(width, 600); svg.attr('width', width).attr('height', height); var xAxisLine = svg.append('svg:g') .attr('class', 'x axis') .attr('transform', 'translate(0,' + (height - margins.bottom) + ')'); var yAxisLine = svg.append('svg:g') .attr('class', 'y axis left') .attr('transform', 'translate(' + (margins.left) + ',0)'); var graphLine = svg.append('svg:path') .attr('class', 'graph-line'); var xRange = d3.time.scale() .range([margins.left, width - margins.right]); var yRange = d3.scale.linear() .range([height - margins.bottom, margins.top]) .domain([0,scope.maxValue]); var xAxis = d3.svg.axis() .scale(xRange) .tickFormat(d3.time.format('%H:%M')) .tickSize(5) .tickSubdivide(true); var yAxis = d3.svg.axis() .scale(yRange) .tickSize(5) .orient('left') .tickSubdivide(true); var valueFunc = d3.svg.line() .x(function(d) { return xRange(d.date); }) .y(function(d) { return yRange(d.value); }) .interpolate('linear'); var redrawData = function redrawData() { xRange.domain([d3.min(scope.values, function(d) { return d.date; }), d3.max(scope.values, function(d) { return d.date; })]); xAxisLine.transition().call(xAxis); yAxisLine.transition().call(yAxis); graphLine.attr('d', valueFunc(scope.values)); }; redrawData(); scope.$watch(function() { return scope.values; }, function() { redrawData(); }, true); }; |
Cuando esta función es invocada, se le pasan dos parámetros:
- El parámetro
scope
es el elementoscope
asociado al componente. Ya he avisado que este elemento es un concepto fundamental de AngularJS, pero explicarlo enmedio del artículo es un poco difícil. De forma resumida, elscope
de ladirective
es un objeto javascript donde se guardan los datos asociados a ladirective
. Entre esos datos, están los dos parámetros definidos (a los que se puede acceder comoscope.maxValue
yscope.values
. - El parámetro
element
es el elemento html donde se aplica ladirective
(nos lo pasa, para darnos la oportunidad de manipularlo).
El código para generar la gráfica, es similar al que usamos en la versión para el plugin jQuery. Excepto que aquí tenemos que usar los elementos propios de la directive
:
- Insertamos el elemento
svg
en el objetoelement
. - El valor máximo para el eje vertical lo obtenemos de
scope.maxValue
. - Los datos para la gráfica los obtenemos de
scope.values
.
La única línea que falta por explicar es la última:
1 |
scope.$watch(function() { return scope.values; }, function() { redrawData(); }, true); |
El modelo de actualización de AngularJS
Para explicar la línea anterior, es necesario que explique cómo AngularJS actualiza el documento html.
AngularJS está diseñado para mantener un estado interno, que guarda en los objetos scope
que ya he mencionado previamente. Este estado interno se supone que tiene su repercusión en el documento html. Básicamente, el documento html se divide en fragmentos, cada uno de los cuales tiene asociado un objeto scope
. Estos objetos scope
se relacionan entre sí formando una árbol similar al que los fragmentos de documento html forman entre sí:
- Si un objeto
scope
ha cambiado, se debe actualizar la parte del documento html asociada. - Si el documento html varía, los
scope
afectados deben actualizarse consecuentemente.
Cuando desarrollas una aplicación AngularJS, no se debe modificar directamente el documento html, te debes limitar a modificar los objetos scope
. Si lo haces así, AngularJS revisa regularmente estos objetos scope
y se encarga de actualizar apropiadamente el documento html:
- Siguiendo ciertas condiciones internas, AngularJS revisa sus elementos
scope
. - Si algún elemento
scope
ha experimentado algún cambio, actualiza el documento html en función de esos elementosscope
- Si algún elemento
scope
ha variado en la última revisión, vuelve a revisar los elementosscope
. - Este proceso de revisión / actualización se repite hasta que una revisión no encuentra ningún elemento
scope
modificado.
Este modelo de actualización puede parecer muy ineficiente (de hecho, es motivo de mucha controversia). Pero los diseñadores de AngularJS lo defienden e insisten que en condiciones normales, es suficientemente rápido para garantizar la experiencia de usuario.
Antes, he mencionado que AngularJS lanza este proceso de revisión “cuando se producen ciertas condiciones internas”. El problema, es que si se producen condiciones externas a AngularJS, el proceso de revisión no se lanza y la actualización del documento html no se ejecuta. Estas «condiciones externas» se producen cuando se integran librerías externas a AngularJS. En nuestro caso, tenemos una clase generadora de datos DataSource
que periódicamente genera nuevos datos. Pero, cuando genera nuevos datos, AngularJS no se entera de estos cambios (y, por lo tanto, no actualiza el documento html). Para estas situaciones, es necesario avisar a AngularJS del cambio de estado que se ha producido. Por eso, cuando creamos el objeto dataSource
dentro de nuestra factory
, programamos el evento onNewValue
de este objeto (que es cuando se generan nuevos datos) para que se lo notifique a AngularJS:
1 |
dataSource.onNewValue(function() { $rootScope.$digest(); }); |
El objeto $rootScope
es el «padre de todas las scope
«. Cuando invocamos su método $digest
le estamos pidiendo: «comienza un proceso de revisión». Este proceso de revisión pedirá a todas las scope
(incluidas las seis scope
asociadas a nuestras seis gráficas) que se revisen sus estados.
Todavía falta una pieza de nuestro puzzle. Antes, he mencionado que cuando un objeto scope
es modificado, el fragmento de documento html asociado debe ser actualizado. Esto, no ocurre por arte de magia ¿Quién es responsable de realizar esta actualización? Pues los responsables son las directive
. Cuando nos limitamos a usar las directive
que viene incluidas con AngularJS, nos despreocupamos de estas actualizaciones. Pero, en nuestro caso, hemos definido nuestra propia directive
, así que tenemos que programar esta actualización. Por eso, al final de nuestro código, incluimos:
1 |
scope.$watch(function() { return scope.values; }, function() { redrawData(); }, true); |
Lo que estamos es pidiéndole al objeto scope
de nuestra directive
que “esté pendiente del valor scope.values
y, si se encuentra que este valor cambia, que llame a la función redrawData
”.
De modo que la secuencia completa sería:
- Un objeto
DataSource
genera nuevos datos. - El evento
onNewValue
del objeto se dispara. - El método
$rootScope.$digest
es invocado y comienza el proceso de revisión. - El proceso de revisión se dispara para cada una de las gráficas. Dentro de cada gráfica, cada una comprueba si su elemento
scope.values
ha variado. - Si una gráfica encuentra que su elemento
scope.values
ha variado, llama a su funciónredrawData
y la gráfica en cuestión es redibujada.
Esto que he explicado, es cierto pero incompleto. Este ciclo de vida es más complejo. Pero espero haber explicado lo suficiente para darle sentido al código que he usado.
¿Conclusiones?
La verdad, es que comparar el código usado para crear nuestro plugin jQuery con este código, la diferencia es abismal. Incluso la tarea de explicarlo se convierte en compleja.
Cuando se comienza a «jugar» con AngularJS, las expectativas son muy altas, por la facilidad que nos da para crear páginas complejas con un código mínimo. Pero conforme comenzamos a tener necesidades más complejas, la curva de aprendizaje se dispara (os recomiendo ver la curva de aprendizaje de AngularJS propuesta por Ben Nadel).
En el caso de esta entrada, nuestro problema es que hemos tenido necesidades complejas: integrar fuentes de datos externas y crear nuestra propia directiva. Esto, ha aumentado la complejidad de nuestro código.
¿Merece la pena el esfuerzo necesario para dominar AngularJS?. Mi opinión personal es que si lo que necesitamos es un pequeño programa, es posible que no. Pero conforme nuestra aplicación Javascript crezca (y las aplicaciones Javascript están creciendo), es cuando el modelo de componentes que te proporciona AngularJS muestra toda su potencia: la aplicación crece, el código asociado crece, pero las piezas están ordenadas y el mantenimiento del código es mucho más efectivo.