#223 Charts
- Download:
- source codeProject Files in Zip (88.5 KB)
- mp4Full Size H.264 Video (31.6 MB)
- m4vSmaller H.264 Video (19 MB)
- webmFull Size VP8 Video (46.1 MB)
- ogvFull Size Theora Video (42.4 MB)
Una buena forma de mostrar resúmenes o estadísticas, si tenemos una aplicación que contiene muchos datos, es mostrar una gráfica con dichos datos. A continuación se muestra una captura de pantalla de la interfaz administrativa de una aplicación de comercio electrónico. Cada pedido tiene un número de pedido, una fecha de compra, un campo que indica si el ítem ha sido enviado y su coste total. Este sistema contiene cientos de pedidos a lo largo de decenas de páginas, lo que hace que sea tedioso calcular a mano cualquier dato estadístico acerca de dichos datos.
Si tuviésemos una gráfica en la parte de superior de la página que mostrase un resumen de los pedidos a lo largo del tiempo nos sería mucho más fácil detectar tendencias en los datos de ventas. Eso es precisamente lo que haremos en este episodio.
Highcharts
Hay muchas librerías de gráficas disponibles pero la que vamos a usar es Highcharts. Se trata de una solución de cliente y utiliza JavaScript y SVG o VML, por lo que no hay dependencias de plugins como Flash o generadores de imágenes en el lado del servidor como ImageMagick. Como veremos, Highcharts puede generar gráficas atractivas con sólo unas líneas de JavaScript.
Hay que tener en cuenta, antes de considerar el uso de Highcharts para nuestros proyectos, que es gratuita sólo para proyectos no comerciales. Si este no es nuestro caso, veremos algunas alternativas más adelante.
Una vez que hayamos
descargado Highcharts tendremos que extraer el fichero highcharts.js
en el directorio /public/javascripts
de nuestra aplicación. También tendremos que añadir la última versión de la librería jQuery y la versión compatible con jQuery de rails.js
, que es necesaria porque se trata de una aplicación Rails 3. Hay más detalles de todo esto en el Episodio 205 [ verlo, leerlo].
Con estos archivos en su sitio ya podemos añadir la siguiente línea a la sección
<head>
del fichero de layout de nuestra aplicación, para que sean correctamente referenciados.
<%= javascript_include_tag "jquery-1.4.2.min", "rails", "highcharts" %>
Highcharts depende de jQuery o MooTools, así que tendremos que incluir una de estas dos librerías antes de cargar el fichero highcharts.js
.
Creación de una gráfica
Una vez que hemos instalado Highcharts podemos empezar añadiendo una gráfica a nuestra página de pedidos. Lo que queremos es añadir una gráfica de línea sencilla que muestre los ingresos obtenidos por día. Lo primero que tenemos que hacer es añadir un elemento vacío en la página de pedidos donde queremos que aparezca la gráfica.
<%= will_paginate(@orders) %> <div id="orders_chart" style="width: 560px; height: 300px;"></div> <table class="pretty"> <!-- Orders table code here --> </table>
Tenemos que dar un id
para poder identificarlo y para poder especificar un ancho y un alto. Por lo general definiríamos el estilo de un elemento en un fichero CSS separado, pero por comodidad lo hemos incluido aquí en el marcado HTML.
A continuación tenemos que añadir el JavaScript para generar la gráfica. Una vez más, en una aplicación de producción tendríamos que crear un fichero separado en lugar de embeberlo pero aquí seguiremos el enfoque más fácil. Nótese que si tuviésemos que extraer el JavaScript a su propio archivo esto tendría el efecto (no deseado) de que el código para generar la gráfica sería más complejo porque vamos a generar JavaScript dinámicamente con ERb. En una aplicación de producción haríamos esto realizando peticiones AJAX según cargue la página, pero con el código en línea que vamos a usar en nuestro ejemplo esto no será un problema.
Queremos que la gráfica sea sólo generada cuando el DOM de la página haya terminado de cargar por lo que rodearemos nuestro código de la función $
de jQuery de forma que el script no sea ejecutado hasta entonces. Dentro de la función crearemos el código necesario para generar una gráfica básica.
<script type="text/javascript" charset="utf-8"> $(function () { new Highcharts.Chart({ chart: { renderTo: 'orders_chart' } }); }); </script>
Hemos creado la gráfica con un nuevo objeto Highcharts.Chart
y le hemos pasado un hash de opciones. Hay múltiples opciones que se le pueden pasar, merece la pena mirar la referencia de la web Highcharts para ver qué es lo que podemos usar.
Para empezar vamos a añadir la opción chart
. Esta opción tiene a su vez una opción renderTo
que recibe el id
de una capa, por lo que le pasaremos el identificador de nuestra capa orders_chart
.
Si ahora recargamos la página de pedidos deberíamos ver en la parte superior una gráfica vacía, lo que nos indica que estamos en el buen camino.
Ahora le vamos añadir más opciones a la gráfica y por supuesto algunos datos que dibujar. Vamos a añadir una opción title
que tiene su propia opción text
para establecer el título de la gráfica, una opción xAxis
que tiene su opción type
puesta a 'datetime'
porque nuestra gráfica mostrará fechas en su eje X, una opción yAxis
con el título 'Dollars'
y por último los datos propiamente dichos.
Una gráfica puede visualizar varias series de datos por lo que tendremos que pasarle un array de hashes a la opción series
. Cada uno de estos hashes puede contener cualquier número de puntos por lo que su valor es un array. En el código de abajo hemos creado una serie de datos con cinco valores para probar que nuestra gráfica funciona antes de añadir los datos de verdad.
$(function () { new Highcharts.Chart({ chart: { renderTo: 'orders_chart' }, title: { text: 'Orders by Day' }, xAxis: { type: 'datetime' }, yAxis: { title: { text: 'Dollars'} }, series: [{ data: [1, 2, 5, 7, 3] }] }); });
Si recargamos la página veremos que ahora se dibuja la gráfica con sus cinco puntos de datos obtenidos del array. Todavía tenemos que trabajar en la línea temporal del eje X pero por ahora estamos avanzando adecuadamente.
En la opción series
podemos activar un par de opciones para especificar el punto de inicio y el intervalo entre puntos en el eje X. La primera es pointInterval
que recibe un número que representa el tiempo entre fechas en milisegundos. Utilizaremos esta opción para que entre cada punto haya un día, y utilizaremos un poco de código ERb para calcular el número de milisegundos que hay en un día. El código Ruby 1.day
nos dará el número de segundos que hay en un día, que tenemos que multiplicar por 1000 para obtener el valor en milisegundos que Highcharts necesita.
La segunda opción es pointStart
, que define la fecha y hora del primer punto. Esta opción requiere, igual que antes, un número de milisegundos y si bien en una aplicación "de verdad" utilizaríamos algo más dinámico para definir la fecha de comienzo aquí utilizaremos una fecha de hace tres semanas, nuevamente utilizando ERb para recuperar ese valor en segundos y multiplicándolo por 1000.
$(function () { new Highcharts.Chart({ chart: { renderTo: 'orders_chart' }, title: { text: 'Orders by Day' }, xAxis: { type: 'datetime' }, yAxis: { title: { text: 'Dollars' } }, series: [{ pointInterval: <%= 1.day * 1000 %>, pointStart: <%= 3.weeks.ago.at_midnight.to_i * 1000 %>, data: [1, 2, 5, 7, 3] }] }); });
Si ahora recargamos la página de pedidos el eje X ya muestra las fechas.
Inclusión de datos reales
Una vez que tenemos la gráfica configurada a nuestro gusto podemos reemplazar los datos de prueba con datos reales obtenidos de nuestra tabla de pedidos. Primero veremos la manera ineficiente de hacerlo porque es más fácil escribir el código de esta manera y luego veremos cómo optimizarlo todo al final del episodio.
Necesitaremos la suma del todal de los pedidos de cada día, así que tendremos que escribir un método de clase en el modelo Order
para recuperar todas las compras de un día determinado y calcular la suma del atributo total_price
de cada una de ellas.
class Order < ActiveRecord::Base def self.total_on(date) where("date(purchased_at) = ?",date).sum(:total_price) end end
Ahora ya podemos usar este método para recuperar los datos de cada uno de los días que aparece en la gráfica y podemos reemplazar los datos de prueba con el siguiente código:
series: [{ pointInterval: <%= 1.day * 1000 %>, pointStart: <%= 3.weeks.ago.at_midnight.to_i * 1000 %>, data: <%= (3.weeks.ago.to_date..Date.today).map { |date| Order.total_on(date).to_f}.inspect %> }]
En el código ERb anterior creamos un rango de fechas entre hoy y tres semanas antes y luego usamos el método map
para iterar sobre ese rango y recuperar el total de los pedidos de cada día. Aplicamos inspect
sobre el resultado para convertirlo a algo que pueda usarse desde JavaScript.
Si recargamos la página otra vez veremos que ya no salen nuestros datos de prueba y que ahora aparecen los datos reales de las últimas tres semanas, con un punto para cada día y los ingresos totales de los pedidos de cada día en dólares.
Tooltips
Aunque nuestra gráfica tiene buen aspecto la información que aparece cuando ponemos el ratón sobre uno de los puntos es mejorable. Podemos hacerlo añadiendo la opción tooltip
al código de la gráfica.
$(function () { new Highcharts.Chart({ chart: { renderTo: 'orders_chart' }, title: { text: 'Orders by Day' }, xAxis: { type: 'datetime' }, yAxis: { title: { text: 'Dollars' } }, tooltip: { formatter: function () { return Highcharts.dateFormat("%B %e %Y", this.x) + ': ' + '$' + Highcharts.numberFormat(this.y, 2); } }, series: [{ pointInterval: <%= 1.day * 1000 %>, pointStart: <%= 3.weeks.ago.at_midnight.to_i * 1000 %>, data: <%= (3.weeks.ago.to_date..Date.today).map { |date| Order.total_on(date).to_f}.inspect %> }] }); });
Esta opción tiene su propia opción formatter
que recibe una función como argumento, la cual debe devolver una cadena que es lo que aparecerá como texto del tooltip. Para esto se pueden utilizar algunos formateadores que proporciona Highcharts, por ejemplo en el código anterior hemos usado dos: uno para formatear la fecha del eje X y otro para el valor numérico del eje Y.
Si volvemos a recargar la página y ponemos el ratón sobre uno de los puntos veremos nuestro tooltip pulcramente formateado.
Visualización de múltiples series
Con esta gráfica de nuestros pedidos es fácil ver tendencias en los datos. En la gráfica anterior puede verse que ha habido un incremento de órdenes desde el 19 de Julio. Para determinar qué es lo que ha causado este alza en las ventas podemos reemplazar la serie que muestra las ventas totales por dos que muestren las ventas de descargas (las que tienen un valor shipping
a false
) y las ventas físicas.
Vamos a tener que distinguir los pedidos que fueron enviados de los que fueron descargados así que lo primero que haremos será añadir dos ámbitos a nuestra clase Order
para que podamos recuperar fácilmente cada tipo de pedido.
class Order < ActiveRecord::Base scope :shipping, where(:shipping => true) scope :download, where(:shipping => false) def self.total_on(date) where("date(purchased_at) = ?",date).sum(:total_price) end end
De vuelta en el código JavaScript de la vista de pedidos tenemos que añadir otra opción series
de forma que se dibujen dos conjuntos de datos. Para distinguir las dos series les hemos dado a cada una de ellas un nombre diferente.
$(function () { new Highcharts.Chart({ chart: { renderTo: 'orders_chart' }, title: { text: 'Orders by Day' }, xAxis: { type: 'datetime' }, yAxis: { title: { text: 'Dollars' } }, tooltip: { formatter: function () { return Highcharts.dateFormat("%B %e %Y", this.x) + ': ' + '$' + Highcharts.numberFormat(this.y, 2); } }, series: [{ name: "Shipping", pointInterval: <%= 1.day * 1000 %>, pointStart: <%= 3.weeks.ago.at_midnight.to_i * 1000 %>, data: <%= (3.weeks.ago.to_date..Date.today).map { |date| Order.shipping.total_on(date).to_f}.inspect %> }, { name: "Download", pointInterval: <%= 1.day * 1000 %>, pointStart: <%= 3.weeks.ago.at_midnight.to_i * 1000 %>, data: <%= (3.weeks.ago.to_date..Date.today).map { |date| Order.download.total_on(date).to_f}.inspect %> }] }); });
Ahora tenemos dos series y hemos usado el correspondiente ámbito en cada uno de ellos para recuperar los datos relevantes para cada día. Lá gráfica ahora mostrará ambas series, con sus nombres en la leyenda inferior.
Ahora podemos ver los totales de ventas físicas y descargadas y podemos ver que el pico de pedidos de los últimos días se corresponde con un incremento en las ventas tanto físicas como de descargas. Obsérvese que cuando un gráfico muestra más de una serie podemos hacer clic en la leyenda para ocultar o mostrar dicha serie.
Eliminando la duplicación
Al añadir una segunda serie a la gráfica hemos introducido cierta duplicidad en el código JavaScript con el problema de que si quisiéramos añadir más series tendríamos que repetirnos todavía más. Podemos atajar este problema utilizando un poco de código Ruby para generar dinámicamente el código JavaScript de cada serie.
$(function () { new Highcharts.Chart({ chart: { renderTo: 'orders_chart' }, title: { text: 'Orders by Day' }, xAxis: { type: 'datetime' }, yAxis: { title: { text: 'Dollars' } }, tooltip: { formatter: function () { return Highcharts.dateFormat("%B %e %Y", this.x) + ': ' + '$' + Highcharts.numberFormat(this.y, 2); } }, series: [ <% { "Download" => Order.download, "Shipping" => Order.shipping }.each do |name, order| %> { name: "<%= name %>", pointInterval: <%= 1.day * 1000 %>, pointStart: <%= 3.weeks.ago.at_midnight.to_i * 1000 %>, data: <%= (3.weeks.ago.to_date..Date.today).map { |date| order.total_on(date).to_f}.inspect %> }, <% end %>] }); });
En lugar de definir cada serie por separado ahora tenemos un hash que define el nombre de cada serie y el código que hay que usar para recuperar los pedidos de cada serie. Luego podemos iterar por ese hash y generar el JavaScript de la serie correspondiente.
Optimización de la consulta
La gráfica ya muestra la información que queremos pero el SQL que se está usando para recuperar los datos es bastante ineficiente. El log de desarrollo nos revela que se hace una consulta separada para cada nodo de la gráfica.
SQL (0.6ms) SELECT SUM("orders"."total_price") AS sum_id FROM "orders" WHERE ("orders"."shipping" = 't') AND (date(purchased_at) = '2010-07-18') SQL (0.6ms) SELECT SUM("orders"."total_price") AS sum_id FROM "orders" WHERE ("orders"."shipping" = 't') AND (date(purchased_at) = '2010-07-19') SQL (0.6ms) SELECT SUM("orders"."total_price") AS sum_id FROM "orders" WHERE ("orders"."shipping" = 't') AND (date(purchased_at) = '2010-07-20') SQL (0.6ms) SELECT SUM("orders"."total_price") AS sum_id FROM "orders" WHERE ("orders"."shipping" = 't') AND (date(purchased_at) = '2010-07-21') SQL (0.7ms) SELECT SUM("orders"."total_price") AS sum_id FROM "orders" WHERE ("orders"."shipping" = 't') AND (date(purchased_at) = '2010-07-22')
Podemos mejorar esto de forma que se lance una única llamada para cada serie utilizando los métodos group
y select
. Queremos recuperar los pedidos del modelo Order
y agruparlos por su purchase_date
y para cada grupo de órdenes devuelto queremos la fecha y el precio total. El siguiente código hace eso:
Order.group("date(purchased_at)").select("purchased_at, sum(total_price) as total_price")
Podemos comprobarlo en la consola y recuperar el total_price
del primer ítem devuelto por la consulta.
> Order.group("date(purchased_at)").select("purchased_at, sum(total_price) as total_price").first.total_price.to_f => 403.0
Pero hay un ligero problema con este enfoque. Si no hay pedidos en un día determinado tendremos un punto ausente en los datos devueltos por lo que el resto de puntos posteriores estarán desplazados un día en la gráfica. Tenemos que tener en cuenta esta posibilidad, lo que va a complicar el código. Para organizarlo lo mejor posible, vamos a llevar esto a un método helper.
$(function () { new Highcharts.Chart({ chart: { renderTo: 'orders_chart' }, title: { text: 'Orders by Day' }, xAxis: { type: 'datetime' }, yAxis: { title: { text: 'Dollars' } }, tooltip: { formatter: function () { return Highcharts.dateFormat("%B %e %Y", this.x) + ': ' + '$' + Highcharts.numberFormat(this.y, 2); } }, series: [ <% { "Both" => Order, "Download" => Order.download, "Shipping" => Order.shipping }.each do |name, order| %> { name: "<%= name %>", pointInterval: <%= 1.day * 1000 %>, pointStart: <%= 3.weeks.ago.at_midnight.to_i * 1000 %>, data: <%= orders_chart_series(orders, 3.weeks.ago) %> }, <% end %>] }); });
Hemos reemplazado el código que recupera los datos por una llamada a un método llamado orders_chart_series
que recibe como argumentos el ámbito relevante del modelo Order
y la fecha de inicio de la serie. Vamos a escribir este nuevo método en el módulo OrdersHelper
.
module OrdersHelper def orders_chart_series(orders, start_time) orders_by_day = orders.where(:purchased_at => start_time.beginning_of_day..Time.zone.now.end_of_day). group("date(purchased_at)"). select("purchased_at, sum(total_price) as total_price") (start_time.to_date..Date.today).map do |date| order = orders_by_day.detect { |order| order.purchased_at.to_date == date } order && order.total_price.to_f || 0 end.inspect end end
En el método orders_chart_series
pasamos el ámbito orders
y recuperamos los pedidos que entran dentro del rango de fechas entre start_time
y la fecha de hoy. Después agrupamos los resultados por día y escogemos la fecha y el total de pedidos de dicho día. Por último iteramos sobre el rango de fechas para recuperar el total de pedidos de cada día, introduciendo un 0
si no se hicieron pedidos ese día.
Si recargamos la página y miramos el log de desarrollo veremos que ahora hemos reducido a dos el número de consultas a la base de datos.
Order Load (2.6ms) SELECT purchased_at, sum(total_price) as total_price FROM "orders" WHERE ("orders"."shipping" = 'f') AND ("orders"."purchased_at" BETWEEN '2010-07-01 00:00:00.000000' AND '2010-07-22 23:59:59.999999') GROUP BY date(purchased_at) Order Load (1.5ms) SELECT purchased_at, sum(total_price) as total_price FROM "orders" WHERE ("orders"."shipping" = 't') AND ("orders"."purchased_at" BETWEEN '2010-07-01 00:00:00.000000' AND '2010-07-22 23:59:59.999999') GROUP BY date(purchased_at)
Alternativas
Highcharts es una librería de gráficas excelente pero puede que no sea exactamente lo que estamos buscando, por lo que finalizaremos este episodio recorriendo algunas alternativas.
La primera de ellas es Flot. También utiliza JavaScript y puede usarse para generar gráficos bastante bonitos.
Otra librería basada en JavaScript es gRraphaël. Es muy útil para generar diagramas de barra o tarta, por lo que se recomienda echarle un vistazo si tenemos que generar este tipo de gráficas.
Y por último tenemos Bluff. Está basada en la librería Gruff de Ruby. Es una solución muy bonita y sencilla, por lo que si buscamos una solución ligera podría interesarnos.
Y eso es todo por este episodio. Si necesitamos mostrar gráficas en nuestra aplicación tenemos muchas opciones donde escoger.