Encapsulando una gráfica realizada con D3.js con Angular.js

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:

Para centrarnos en las diferencias, comparémoslo con el código html que usamos para la versión jQuery:

  • Al contenedor principal, le he añadido dos nuevos atributos ng-app y ng-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 componente controller a un determinado elemento del documento. Más adelante, veremos la definición de este controller y para qué sirve. Además, indicamos que a este controller lo vamos a identificar como mainCtrl.
  • 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 es directive) con la gráfica que vamos a encapsular y al que he llamado jmj-graph.
    • Todos tienen dos nuevos atributos data-values y data-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 son mainCtrl.xxx (acordaros que en el punto anterior definimos un controller identificado como mainCtrl).

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:

(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:

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:

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:

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):

Angular.js admite hasta tres variantes para definir los componentes. Este mismo componente, también podría haberlo definido así:

O así:

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 tipo DataSource y registra una función anónima a su evento onNewValue que invoca al método $digest del componente $rootScope del que depende mi recién definida factory (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:

  • 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 componente dataSourceFactory, para eso lo necesita).

Ahora, con esta definición leída, vamos a leer de nuevo nuestro código html:

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:

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:

Vamos a leerlos juntos:

  • Defino un componente directive de nombre jmjGraph. 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 como jmj-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 como data-jmj-graph o jmj:graph o x: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:

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:

Cuando esta función es invocada, se le pasan dos parámetros:

  • El parámetro scope es el elemento scope 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, el scope de la directive es un objeto javascript donde se guardan los datos asociados a la directive. Entre esos datos, están los dos parámetros definidos (a los que se puede acceder como scope.maxValue y scope.values.
  • El parámetro element es el elemento html donde se aplica la directive (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 objeto element.
  • 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:

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 elementos scope
  • Si algún elemento scope ha variado en la última revisión, vuelve a revisar los elementos scope.
  • 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 onNewValuede este objeto (que es cuando se generan nuevos datos) para que se lo notifique a AngularJS:

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:

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:

  1. Un objeto DataSource genera nuevos datos.
  2. El evento onNewValue del objeto se dispara.
  3. El método $rootScope.$digest es invocado y comienza el proceso de revisión.
  4. 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.
  5. Si una gráfica encuentra que su elemento scope.values ha variado, llama a su función redrawData 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.

Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

*
*
Sitio Web