Muchas veces necesitamos mostrar información de tal manera que podamos comprenderla mejor que si la vemos en una tabla, para estos casos un componente visual es lo que puede integrar el contexto con la información y darnos ese plus. Desarrolar con frameworks javascript nos ayuda, ya que estos traen incorporados una gran variedad de gráficos, y también nos brindan la posibilidad de generar nuestros própios componentes que se ajusten a nuestras necesidades.
Introducción
El lanzamiento de Sencha ExtJS 4 estuvo marcado por la integración nativa de gráficos, tal es así que inmediatamente la comunidad pidió esta característica para Touch. Algunos decidieron exportar los gráficos de ExtJS4 a Touch de manera independiente (sin apoyo oficial), como es el caso de Oppo-Touching.
En esta entrada vamos a generar 2 componentes visuales, un Semaforo y un Medidor de aguja, ambos totalmente compatibles con Sencha Touch ayudandonos de Oppo-Touching 2.0.
Aclaración 1: de aquí en adelante nos vamos a referir tanto a Oppo-touching como a ExtJS4-Charts como lo mismo, y en preferencia, a este último ya que contamos con la API oficial.
Instalación
Descargamos el framework desde el repositorio de Google Codes y como cualquier otro proyecto javascript, solo basta con importar el archivo .js en el header del index.html de nuestra aplicación para poder usarlo.
La Arquitectura básica de los componentes es una Superficie (Surface), en el siguiente nivel los motores que renderizan el gráfico (que pueden ser VML, SVG o Canvas, lo que nos brinda una excelente covertura en los navegadores), y en el último nivel los Sprites que vienen a ser los elementos individuales que componen el gráfico.
Los motores no son mas que las diferentes opciones para dibujar que nos provee HTML5: Canvas, SVG y, el propietario de IE, VML.
Los Sprites son los objetos que componen la superfície del dibujo, existen diferentes tipos y opciones. Estos tienen varias propiedades como el tipo, ancho, alto, coordenada X e Y, relleno, etc.
Para construir nuestro componente personalizado vamos a trabajar agregando y dándoles distintas propiedades a nuestros sprites con el objeto de cumplir con todos los requerimientos que tengamos del componente.
Vamos a tener 2 tipos de Sprites, Estáticos o Dinámicos por así decir, los primeros se dibujarán al momento de renderizar el gráfico y no los modificaremos en toda la vída de este, y los dinámicos van a variar según el comportamiento que le demos a estos, como es la lúz que se enciende en el caso de semáforo, o la aguja en el medidor.
Como vamos a extender la clase Ext.draw.Component, los sprites estáticos los agregaremos al momento de crearse mediante un arreglo de items que nos provee esta clase. Es realmente importante crearlos y aplicar estos dentro del constructor mediante la función Ext.apply() ya que sino no podremos crear mas de 1 instancia del gráfico.
En cambio, los dinámicos los agregaremos al momento de renderizar el dibujo y los mantendremos referenciados mediantes variables para poder luego actualizarle sus propiedades.
Semáforo
Comenzaremos con una versión simplificada de nuestro componente, y a medida que avancemos le iremos agregando el comportamiento deseado y las características própias.
// file: Semaforo.js
var rojoApagado = '#0f0000',
verdeApagado = '#000f00',
amarilloApagado = '#0f0f00',
rojoIluminado = '#ff0000',
verdeIluminado = '#00ff00',
amarilloIluminado = '#ffff00';
Ext.bairesit.draw.Semaforo = Ext.extend(Ext.draw.Component, {
/**
* Extendemos el método 'constructor' para darle nuestras
* propiedades personalizadas, como ser el valorActual,
* los límites con los que encenderá cada lúz, los sprites, etc.
*/
constructor:function(config) {
var valorActual = null;
var limiteObjetivo = null;
var limiteAlerta = null;
var esAutoRedibujar = false;
var spriteLuzEncendida = new Array();
var gradients = [{
id : 'gradientIdSemaforo',
angle : 45,
stops : {
0 : {color: '#e0fea7'},
100 : {color: '#ffdeb3'}
}
}];
var circuloLuzEncendida = [{
type : 'circle',
fill : verdeIluminado,
radius: 15,
x : 30,
y : 0,
stroke: "333"
}];
var items = new Array({
type : 'circle',
radius: 0,
x : 1,
y : 1
},{
type : 'circle',
radius: 1,
x : 180,
y : 120
},{
type : 'rect',
width : 40,
height: 110,
fill : 'url(#gradientIdSemaforo)',
x : 10,
y : 10,
stroke: "000"
},{
type : 'circle',
radius: 15,
fill : rojoApagado,
x : 30,
y : 30,
stroke: "333"
},{
type : 'circle',
radius: 15,
fill : amarilloApagado,
x : 30,
y : 65,
stroke: "333"
},{
type : 'circle',
radius: 15,
fill : verdeApagado,
x : 30,
y : 100,
stroke: "333"
});
config = Ext.apply({
items : items,
gradients : gradients,
valorActual : valorActual,
limiteAlerta : limiteAlerta,
limiteObjetivo : limiteObjetivo,
esAutoRedibujar : esAutoRedibujar,
spriteLuzEncendida : spriteLuzEncendida,
circuloLuzEncendida: circuloLuzEncendida
}, config);
Ext.bairesit.draw.Semaforo.superclass.constructor.apply(this, arguments);
},
onRender: function(){
Ext.bairesit.draw.Semaforo.superclass.onRender.apply(this, arguments);
this.spriteLuzEncendida[0] = this.surface.add(this.circuloLuzEncendida[0]);
if (this.valorActual != null){
this.redraw();
}
}
}
Los Sprites dinámicos que queremos mantener en variables para poder modificar los agregamos al extender la función onRender, para así luego por ejemplo, si actualizamos el valorActual y queremos iluminar el foco correspondiente, le actualizamos sus atributos de la siguiente manera:
iluminarSemaforo: function () {
var ejeY= 0;
var urlFill='';
if (this.valorActual >= this.limiteAlerta){
ejeY = 30;
urlFill = rojoIluminado;
} else {
if (this.valorActual <= this.limiteObjetivo){
ejeY = 100;
urlFill = verdeIluminado;
} else {
ejeY = 65
urlFill = amarilloIluminado;
}
}
this.spriteLuzEncendida[0].setAttributes({
y : ejeY,
fill : urlFill
});
this.spriteLuzEncendida[0].redraw();
}
Gradientes
Una forma de mejorarle el aspecto es utilizando gradientes de colores para realzar la calidad gráfica. Si bien con Ext.draw.Component podemos generar y utilizar gradientes, éstos pueden ser solamente ‘lineales’. Para las luces iluminadas del semaforo es mucho mas elegante un grandiente radial y un hack para utilizarlos es definiendolos directamente en el index.html de manera explícita dentro de las etiquetas body:
...
Una vez definido, para poder utilizarlo solo deberemos referenciarlos con el id, por ejemplo: fill: 'url(#radialGradientRojo)'.
Basicamente este componente ya funciona y podemos seguir extendiendolo y agregandole mas sprites y características como ser un título, una leyenda que muestre el valorActual, un store que almacene el historial, la posibilidad de dibujar la tendencia que viene teniendo lo que estemos midiendo, etc.
En la imagen 02 se puede apreciar el resultado final del componente y una serie de estados de ejemplo.
Crear un medidor de agujas supone un mayor trabajo ya que implican cálculos adicionales y una gran cantidad de sprites dinámicos.
Los requerimientos que tenemos son:
- Setear los limites inferiores y superiores.
- Limites de objetivo y de alerta.
- Apreciar graficamente el estado del valor actual.
- Posibilidad de visualizar el limite del objetivo.
- Posibilidad de apreciar la tendencia.
- Posibilidad de apreciar la evolución del valor actual.
Dividimos en SubComponentes y los clasificamos:
| Fondo del Medidor | → | Estático |
| Líneas de Ejes | → | Estático |
| Valores de Ejes | → | Dinámico |
| Aguja | → | Dinámico |
| Recuadro de leyenda y valor actual | → | Dinámico |
| Tendencia | → | Dinámico |
| Aguja objetivo | → | Dinámico |
Bien, una vez que desglosamos los componentes requeridos, comenzamos con la construcción de los componentes estáticos:
// file: Medidor.js
Ext.bairesit.draw.Semaforo = Ext.extend(Ext.draw.Component, {
constructor:function(config) {
...
var items = [{ // Punto para asegurar los límites del gráfico
type : 'circle',
radius: 0,
x : 1,
y : 1
},{ // Punto para asegurar los límites del gráfico
type : 'circle',
radius: 1,
x : 415,
y : 140
},{ // medio círculo
type : 'path',
fill : 'url(#gradientIdMedidor)',
path : "M30,130 C36,-2 224,-2 230,130 L 200,130 C 194,38 66,38 60,130 z","stroke-width":"1",
stroke: "#000"
},{ // línea de cierre
type : 'path',
fill : '#000',
path : "M25 130L 235 130","stroke-width":"1",
stroke: "#000"
},{ // línea a los 18°
type : 'path',
fill : '#fff',
path : "M37.272 99.871L 32.517 98.326","stroke-width":"1",
stroke: "#000"
},{ // línea a los 36°
type : 'path',
fill : '#fff',
path : "M51.121 72.691L 46.076 69.752","stroke-width":"1",
stroke: "#000"
},{ // línea a los 54°
type : 'path',
fill : '#fff',
path : "M72.691 51.121L 69.752 47.076","stroke-width":"1",
stroke: "#000"
},{ // línea a los 72°
type : 'path',
fill : '#fff',
path : "M99.871 37.272L 98.8326 32.517","stroke-width":"1",
stroke: "#000"
},{ // línea a los 90°
type : 'path',
fill : '#fff',
path : "M130 25L 130 35","stroke-width":"1",
stroke: "#000"
},{ // línea a los 108°
type : 'path',
fill : '#fff',
path : "M160.129 37.272L 161.674 32.517","stroke-width":"1",
stroke: "#000"
},{ // línea a los 126°
type : 'path',
fill : '#fff',
path : "M187.309 51.121L 190.248 47.076","stroke-width":"1",
stroke: "#000"
},{ // línea a los 144°
type : 'path',
fill : '#fff',
path : "M208.879 72.691L 212.924 69.752","stroke-width":"1",
stroke: "#000"
},{ // línea a los 162°
type : 'path',
fill : '#fff',
path : "M222.728 99.871L 227.483 98.326","stroke-width":"1",
stroke: "#000"
},{ // circulo aguja
type : 'circle',
fill : 'url(#radialGradientCentroAguja)',
radius: 8,
x : 130,
y : 130
}];
config = Ext.apply({
items : items,
...
}, config);
Ext.bairesit.draw.Medidor.superclass.constructor.apply(this, arguments);
}
...
}
Ahora agregamos los sprites dinámicos, como ser los Valores del Eje:
constructor:function(config) {
...
var items ...
var spriteAxis = new Array();
var axis = [{
type : 'text',
text : 0,
fill : '#000',
font : '10px Arial',
x : 130,
y : 10
},{
type : 'text',
text : 1,
fill : '#000',
font : '10px Arial',
x : 130 + 1,
y : 10
},{
type : 'text',
text : 2,
fill : '#000',
font : '10px Arial',
x : 130 + 2,
y : 10
},{
type : 'text',
text : 3,
fill : '#000',
font : '10px Arial',
x : 130 + 3,
y : 10
},{
type : 'text',
text : 4,
fill : '#000',
font : '10px Arial',
x : 130 + 4,
y : 10
},{
type : 'text',
text : 5,
fill : '#000',
font : '10px Arial',
x : 130 + 5,
y : 10
},{
type : 'text',
text : 6,
fill : '#000',
font : '10px Arial',
x : 130 + 6,
y : 10
},{
type : 'text',
text : 7,
fill : '#000',
font : '10px Arial',
x : 130 + 7,
y : 10
},{
type : 'text',
text : 8,
fill : '#000',
font : '10px Arial',
x : 130 + 8,
y : 10
},{
type : 'text',
text : 9,
fill : '#000',
font : '10px Arial',
x : 130 + 9,
y : 10
},{
type : 'text',
text : 10,
fill : '#000',
font : '10px Arial',
x : 130 + 10,
y : 10
}];
config = Ext.apply({
items : items,
gradients : gradients,
valorActual : valorActual,
valorMaximo : valorMaximo,
valorMinimo : valorMinimo,
limiteObjetivo : limiteObjetivo,
limiteAlerta : limiteAlerta,
esMinimoMejor : esMinimoMejor,
esAutoRedibujar : esAutoRedibujar,
axis : axis,
spriteAxis : spriteAxis,
...
}, config);
Ext.bairesit.draw.Medidor.superclass.constructor.apply(this, arguments);
},
anguloARadian: function (angulo){
var radianes = 0;
radianes = (angulo * Math.PI) / 180;
return radianes;
},
calcularCoordenadaX: function (anguloGrados, radio, offset){
var radianes = this.anguloARadian(anguloGrados);
var x = (offset + (Math.cos(radianes) * radio));
return x;
},
calcularCoordenadaY : function (anguloGrados, radio, offset){
var radianes = this.anguloARadian(anguloGrados);
var y = (offset - (Math.sin(radianes) * radio));
return y;
},
dibujarAxis: function(){
var i = 0, texto = '', arreglo;
var paso = (this.valorMaximo - this.valorMinimo) / 10;
for ( i = 0 ; i <= 10 ; i++ ){
this.axis[i].text = this.valorMinimo + (paso * i);
this.axis[i].x = 130 + (i * paso);
/* Dibujo los numeros de la escala */
arreglo = (String(this.axis[i].text)).split(".");
texto = arreglo[0] + '.' + ( arreglo[1] ? arreglo[1].substring(0, 1) : '0');
this.spriteAxis[i].setAttributes({
text : texto,
x : this.calcularCoordenadaX (180-(180/10*i), 115, 120),
y : this.calcularCoordenadaY (180-(180/10*i), 110, 125)
});
this.spriteAxis[i].redraw();
}
},
onRender: function(){
Ext.bairesit.draw.Medidor.superclass.onRender.apply(this, arguments);
...
for ( i = 0 ; i <= 10 ; i++ ){
this.spriteAxis[i] = this.surface.add(this.axis[i]);
},
...
},
Agregamos la Aguja:
constructor:function(config) {
...
var spriteAguja = new Array();
var circulo = [{
type : 'circle',
fill : 'url(#radialGradientCentroAguja)',
radius: 8,
x : 130,
y : 130
}];
var aguja = [{
type : 'path',
fill : 'url(#gradientId)',
path : "M 93,95 "+10+","+0+" 107,95 z","stroke-width":"1",
stroke: "#333"
}];
config = Ext.apply({
spriteAguja : spriteAguja,
aguja : aguja,
circulo : circulo,
...
}, config);
Ext.bairesit.draw.Medidor.superclass.constructor.apply(this, arguments);
},
dibujarAguja: function () {
var opacity = 0;
var radio, angulo, alfa, puntaX0, puntaY0, beta, puntaX1, puntaY1, gama, puntaX2, puntaY2;
if ((this.valorMinimo <= this.valorActual) && (this.valorActual <= this.valorMaximo)){
radio = 90;
angulo = (((this.valorActual - this.valorMinimo) /(this.valorMaximo - this.valorMinimo)) * 180) ;
alfa = 180 - angulo + 90;
puntaX0 = this.calcularCoordenadaX (alfa, 6, 130);
puntaY0 = this.calcularCoordenadaY (alfa, 6, 130);
beta = 180 - angulo;
puntaX1 = this.calcularCoordenadaX (beta, radio, 130);
puntaY1 = this.calcularCoordenadaY (beta, radio, 130);
gama = 180 - angulo - 90;
puntaX2 = this.calcularCoordenadaX (gama, 6, 130);
puntaY2 = this.calcularCoordenadaY (gama, 6, 130);
opacity = 1;
/* Dibujo la Aguja */
this.spriteAguja[0].setAttributes({
path: "M "+puntaX0+","+puntaY0+" "+
puntaX1+","+puntaY1+" "+
puntaX2+","+puntaY2+" z",
"stroke-width":"1"
});
}
this.spriteAguja[0].setAttributes({opacity:opacity});
this.spriteCirculo[0].setAttributes({opacity:opacity});
this.spriteAguja[0].redraw();
this.spriteCirculo[0].redraw();
},
onRender: function(){
Ext.bairesit.draw.Medidor.superclass.onRender.apply(this, arguments);
...
for (var r = 0; r < this.aguja.length ; r = r+1){
this.spriteAguja[r] = this.surface.add(this.aguja[r]);
};
for (var b = 0; b < this.circulo.length; b = b+1){
this.spriteCirculo[b] = this.surface.add(this.circulo[b]);
};
...
},
...
Gráfico de Líneas
Para poder mostrar la evolución del valor actual decidimos utilizar un gráfico de línea provisto por Oppo-touching, y la manera de activarlo es mediante un doble click (o doble tap) en la flecha de tendencia. Para ello, luego de renderizar el componente, le anexamos el comportamiento al sprite de la tendencia.
afterRender:function(){
Ext.bairesit.draw.Medidor.superclass.afterRender.call(this);
for (var i = 0 ; i < this.spriteTendencia.length ; i++ ){
this.spriteTendencia[i].el.on(
'doubletap',
this.mostrarDetalle,
this
)
}
},
mostrarDetalle: function (){
var desplegableHistorial = new Ext.bairesit.draw.LineChart({
tituloEjeY : this.tituloEjeY,
tituloEjeX : this.tituloEjeX,
maxY : this.valorMaximo,
titulo : this.titulo,
store : this.store,
esMinimoMejor: this.esMinimoMejor
});
desplegableHistorial.setCentered(true);
desplegableHistorial.show();
}
// file: LineChart.js
Ext.namespace('Ext.bairesit.draw');
var naranja = "#eea800";
var esmeralda = "#00eeca";
var amarillo = "#ffffda";
var verde = "#dfffdf";
var azul = "#0080ff";
var rojo = "#FFCCCC";
var tituloAlertaPeligro = "Límite Alerta-Peligro";
var tituloObjetivoAlerta = "Límite Objetivo-Alerta";
var tituloPeligroAlerta = "Límite Peligro-Alerta";
var tituloAlertaObjetivo = "Límite Alerta-Objetivo"
var colorSeparadorSup = naranja;
var colorSeparadorInf = esmeralda;
var coloresAscendentes = [amarillo,verde,azul];
var coloresDescendentes = [amarillo,rojo,azul];
var fondo = rojo;
var colores = coloresAscendentes;
var tituloSuperior = tituloAlertaPeligro;
var tituloInferior = tituloObjetivoAlerta;
Ext.bairesit.draw.LineChart = Ext.extend(Ext.Panel, {
ui : 'dark',
fullscreen : true,
floating : true,
modal : true,
centered : true,
layout : 'fit',
constructor:function(config) {
var maxY = maxY;
var titulo = titulo;
var tituloEjeY = tituloEjeY;
var tituloEjeX = tituloEjeX;
var esMinimoMejor = esMinimoMejor;
var dockedItems = [{
dock : 'top',
xtype : 'toolbar',
title : titulo
}];
config = Ext.apply({
titulo : titulo,
tituloEjeY : tituloEjeY,
tituloEjeX : tituloEjeX,
dockedItems : dockedItems
}, config);
Ext.bairesit.draw.LineChart.superclass.constructor.apply(this, arguments);
},
initComponent: function(config){
Ext.bairesit.draw.LineChart.superclass.initComponent.apply(this, arguments);
this.setTitulo(this.titulo);
},
/**
* Lazy creation
* When the parent is added into UI display list at the first time, we add the charting
*/
doComponentLayout : function(width, height, isSetSize) {
width = width * 0.8;
height = height * 0.7;
this.display(width, height);
Ext.bairesit.draw.LineChart.superclass.doComponentLayout.apply(this, arguments);
},
display:function(width, height) {
if(this.chart != null && this.chart.width == width && this.chart.height == height) {
return;
}
if (!this.esMinimoMejor){
colores = coloresDescendentes;
fondo = verde;
tituloInferior = tituloPeligroAlerta;
tituloSuperior = tituloAlertaObjetivo;
colorSeparadorSup = esmeralda;
colorSeparadorInf = naranja;
}
Ext.chart.theme.Objetivo = Ext.extend(Ext.chart.theme.Category5, {
constructor: function(config) {
Ext.chart.theme.Category5.prototype.constructor.call(this, Ext.apply({
seriesThemes: [{
fill : colorSeparadorSup,
stroke : colorSeparadorSup,
'stroke-width': 1.5
},{
fill : colorSeparadorInf,
stroke : colorSeparadorInf,
'stroke-width': 1.5
}, {
fill: azul
}],
colors : colores
}, config));
}
});
this.chart = new Ext.chart.Chart({
insetPadding: 30,
shadow : true,
theme : 'Objetivo',
legend : {
position: 'right',
boxFill : '#eee'
},
axes : [{
type : 'Numeric',
minimum : 0,
maximum : this.maxY,
position: 'left',
fields : ['valor','objetivo','alerta'],
title : this.tituloEjeY,
grid: {
odd: {
opacity : 1,
fill : fondo,
stroke : fondo,
'stroke-width' : 0.5
},
even: {
opacity : 1,
fill : fondo,
stroke : fondo,
'stroke-width' : 0.5
}
}
},{
type : 'Category',
position: 'bottom',
fields : ['mes'],
title : this.tituloEjeX,
label : { rotate: { degrees : 315 } }
}],
series : [{
type : 'line',
title : tituloSuperior,
axis : 'left',
fill : true,
xField : 'mes',
yField : 'alerta',
style : { opacity: 1 },
showMarkers : false
},{
type : 'line',
title : tituloInferior,
axis : 'left',
fill : true,
xField : 'mes',
yField : 'objetivo',
style : { opacity: 1 },
showMarkers : false
},{
type : 'line',
title : 'Valor',
axis : 'left',
xField : 'mes',
yField : 'valor',
highlight : {
size: 7,
radius: 7
},
markerCfg : {
type : 'circle',
size : 4,
radius : 4,
'stroke-width': 0
}
}]
});
this.chart.setSize(width, height);
this.add(this.chart);
this.chart.bindStore(this.store,true);
},
setTitulo: function(titulo){
this.dockedItems.items[0].title = titulo;
},
refresh:function(data){
this.dockedItems.items[0].title = this.titulo;
this.chart.bindStore(data, true);
this.chart.redraw();
}
});
Resultado Final
Para finalizar, revisamos nuevamente todo el código, perfeccionamos todos los detalles, y testeamos.
La siguiente imagen es una captura de los componentes una vez finalizado el trabajo:
Aclaraciones finales
A esta altura muchos se preguntarán porque no decidimos utilizar Sencha Touch Charts para la confección de nuestros componentes, para ello tenemos en nuestro descargo que al momento de comenzar el trabajo aun no estaban disponibles ningunas de las versiones de Touch Charts y que estas fueron liberadas practicamente al finalizar con la confección de los gráficos. Sin embargo, igualmente intentamos migrar el código para que funciones con el paquete oficial de Sencha pero no tuvimos éxito debido a que Sencha decidió utilizar solamente el motor Canvas para la capa media por una cuestión de compatibilidad con la mayor parte de dispositivos móviles (Android anterior a 3.1 no soporta SVG). Siendo el mayor problema para nosotros que con Canvas todo el gráfico se comporta como una unidad y no nos permite definirle comportamiento a los sprites individualmente como ser el reconocimiento de eventos. Entre nuestros requerimientos se encontraba que el componente sea capaz de mostrar la evolución de los valores y la manera elegida fue que el sprite de la flecha de tendencia reconozca el doble click. Canvas no nos permite esto, por lo tanto, decidimos continuar con el desarrollo utilizando Oppo-touching.
Conclusiones
Podemos concluir que en los casos en que no dispongamos de gráficos que cumplan con nuestros requerimientos, utilizando Oppo-touching (y en algunos casos Touch-Charts) podemos generar nuestros própios componentes, ya que esta librería nos provee todas las herramientas para ello.
Utilizar el addons Touch-Charts con soporte oficial de Sencha nos permitiría ciertas ventajas que no disponemos con Oppo-touching ya que este esta basado en ExtJS4-Charts y no fue pensado para interactuar con dispositivos móviles, con pantallas de tamaños reducidos, con el cambio de orientación horizontal o vertical, con capacidades de procesamiento limitadas, reconocimiento de eventos táctiles, etc.
Como desafio hacia el futuro, esperamos poder migrar a Touch-Charts y de alguna manera cumplir con todos los requerimientos mediante una activación alterna de la vista previa de la evolución histórica del estado.
Referencias
Por el equipo del Laboratorio de Investigación Tecnológica (LIT) de Baires IT


