#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)
Se avete un’applicazione che contiene molti dati, un buon modo di mostrarne un riassunto o delle statistiche agli utenti è quello di ricorrere a grafici o diagrammi. Di sotto è riportato uno screenshot dalla pagina di amministarzione di un sito di e-commerce. Ogni ordine elencato ha un numero di ordine, una data di acquisto, un campo che indica se l’oggetto è stato spedito o acquistato in negozio e un totale del costo della merce. Il sistema contiene centinaia di ordini fra dozzine di pagine, per cui calcolare un qualunque tipo di statistica sugli ordini a mano sarebbe a dir poco noioso:
Potremmo avere un’idea del trend delle vendite in modo più semplice se usassimo un grafico in cima alla pagina, mostrante l’andamento delle vendite nel tempo.
E’ proprio ciò che faremo in questo episodio.
Highcharts
Ci sono molte validissime librerie grafiche disponibili, ma quella su cui ci focalizzeremo oggi è Highcharts. Si tratta di una soluzione lato client che fa uso di JavaScript e o SVG o VML, per cui non presenta dipendenze verso altri plugin tipo Flash o altri generatori grafici lato server tipo ImageMagick. Come vedremo presto, Highcharts è in grado di generare bellissimi grafici con solo poche righe di codice JavaScript.
Prima di prendere in considerazione Highcharts per un qualunque progetto, vale la pena ricordarsi che si tratta di una libreria gratuita solo per usi in prodotti non commerciali. Se questo limite non dovesse essere accettabile, alla fine vi presenteremo un paio di alternative.
Dopo aver scaricato Highcharts, dobbiamo estrarre il file highcharts.js nella cartella /public/javascripts della nostra applicazione. Dobbiamo inoltre avere l’ultima libreria jQuery ed una versione jQuery compatibile del file rails.js
, che serve, dal momento che la nostra è un’applicazione Rails 3. Ci sono ulteriori dettagli su come ottenere tutta questa configurazione nell’episodio 205 [ guardalo, leggilo].
Sistemati i file, aggiungiamo la seguente riga di codice alla sezione <head>
del file di layout della nostra applicazione, in modo che questi siano riferiti correttamente:
<%= javascript_include_tag "jquery-1.4.2.min", "rails", "highcharts" %>
Highcharts necessita o di jQuery o di MooTools, per cui dobbiamo includere uno di questi due prima dell’inclusione del file highcharts.js
.
Aggiungiamo un grafico
Ora che abbiamo configurato Highcharts, possiamo cominciare ad aggiungere un grafico alla nostra pagina degli ordini. Ciò che vogliamo aggiungere è un semplice grafico lineare che mostri gli incassi giornalieri. La prima cosa che dobbiamo fare è aggiungere un elemento di placeholder nella pagina degli ordini nel punto in cui vogliamo sia mostrato il grafico:
<%= will_paginate(@orders) %> <div id="orders_chart" style="width: 560px; height: 300px;"></div> <table class="pretty"> <!-- Orders table code here --> </table>
Dobbiamo fornire un id all’elemento in modo tale da poterlo riferire univocamente in seguito nel codice JavaScript; inoltre gli diamo anche un’altezza e una larghezza. Normalmente definiremmo queste due ultime proprietà in uno stile CSS su di un file a parte, ma per comodità in questo caso lo facciamo inline.
Ora dobbiamo scrivere il JavaScript per generare il grafico. Nuovamente, sottolineiamo che in un caso reale scriveremmo questo codice in un file a parte, ma sempre per praticità didattica, in questo esempio facciamo tutto inline. Si noti che se avessimo estratto il JavaScript in un file esterno, sarebbe stato più difficile creare il grafico, poichè genereremo parte del Javascript dinamicamente con erb. In un contesto di produzione potremmo fare questa cosa effettuando request AJAX al caricamento della pagina, ma comunque, con il codice inline, tutto ciò non rappresenta alcun problema.
Vogliamo che il grafico sia generato solo quando il DOM della pagina è stato caricato completamente, per cui racchiuderemo il nostro codice in una funzione $
di jQuery, in modo tale che lo script non sia eseguito fintanto che la pagina non si sia caricata completamente. Dentro tale funzione, inseriamo il codice per creare un grafico basilare:
<script type="text/javascript" charset="utf-8"> $(function () { new Highcharts.Chart({ chart: { renderTo: 'orders_chart' } }); }); </script>
Creiamo un grafico istanziando un nuovo oggetto Highcharts.Chart
e passandogli un hash di opzioni. Ci sono molte opzioni differenti che possono essere passate ed è bene dare un’occhiata al reference delle opzioni sul sito di Highcharts per vedere quali siano in dettaglio.
Per cominciare, aggiungiamo una opzione chart
. Questa opzione ha a sua volta un’opzione renderTo
che accetta un div o l’id
di un div, per cui lo usiamo passandogli l’id
del nostro div placeholder orders_chart
.
Se ora ricarichiamo la pagina degli ordini, dovremmo vedere un grafico vuoto in cima alla pagina, che indica che tutto funziona come dovrebbe:
Ora aggiungeremo un po’ di altre opzioni al grafico, fornendogli anche qualche dato da mostrare. Dopo le opzioni del grafico, aggiungiamo l’opzione title
con a sua volta una propria opzione text
per impostare il titolo del grafico, una opzione xAxis
che ha una opzione type impostata a 'datetime'
, dal momento che il nostro grafico mostra delle date sull’asse X, una opzione yAxis
con title 'Dollars'
e infine i dati veri e propri.
Un grafico può mostrare più serie di dati numerici, per cui dobbiamo passare un array di hash all’opzione series
. Ciascun hash in tale array può contenere un qualsiasi numero di punti, per cui ha a sua volta un array come proprio valore. Nel codice riportato di sotto abbiamo creato una serie di dati con cinque valori per testare che il nostro grafico funzioni bene prima di aggiungere i dati veri e propri:
$(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] }] }); });
Ricaricando la pagina vedremo disegnato il grafico con i suoi cinque punti passati nell’array. La linea temporale sull’asse x ha bisogno di una revisione, ma stiamo facendo buoni progressi.
Possiamo impostare un paio di opzioni all’interno dell’opzione series
, per indicare il punto di partenza e l’intervallo fra i punti per l’asse x del grafico. Il primo è pointInterval
che accetta un numero che rappresenta il tempo (in millisecondi) fra i punti. Lo useremo per piazzare ogni punto separato di un giorno dall’altro e inseriamo del codice erb per calcolare il numero di millisecondi in un giorno. Il codice Ruby 1.day
ci aiuta dandoci il numero di secondi in un giorno, per cui a noi resta solo da moltiplicare tale valore per 1000 per ottenere il valore in millisecondi, come si aspetta Highcharts.
La seconda opzione è pointStart
, che definisce la data e l’ora del primo punto. Di nuovo, questa opzione deve essere data in millisecondi e sebbene useremmo qualcosa di più dinamico per definire il punto di partenza in un applicazione “reale”, in questo esempio useremo semplicemente una data di tre settimane fa, usando nuovamente erb per ottenere il valore in secondi che poi moltiplichiamo per 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] }] }); });
Quando ricarichiamo la pagina degli ordini di nuovo, l’asse x ora riporta le date.
Immissione di dati reali
Ora che abbiamo abbozzato il grafico come volevamo, sostituiamo i dati di prova con i dati concreti provenienti dalla tabella degli ordini. Inizialmente, vi mostreremo il modo inefficiente per farlo, perchè è anche il più semplice da scrivere come codice, poi vedremo come ottimizzarlo alla fine dell’episodio.
Abbiamo bisogno della somma dei totali per ogni ordine di ogni giorno, per cui scriviamo un metodo di classe nel nostro modello Order
per ottenere tutti gli acquisti dato un giorno e sommiamo il total_price
di ciascun ordine:
class Order < ActiveRecord::Base def self.total_on(date) where("date(purchased_at) = ?",date).sum(:total_price) end end
Ora possiamo usare questo metodo per ottenere i dati per ciascun giorno, necessari al nostro grafico, per cui sostituiamo i dati di test nella serie con il seguente codice:
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 %> }]
Nel codice erb di sopra creiamo un intervallo di date da tre settimane fa ad oggi e poi usiamo map
per iterare su tale range per ottenere il totale degli ordini di ciascun giorno. Poi invochiamo la inspect
sul risultato per convertirlo in qualcosa che possa essere utilizzato da del JavaScript.
Se ricarichiamo la pagina nuovamente, vedremo che i dati di test sono stati rimpiazzati con i dati effettivi delle ultime tre settimane, con un punto per ciascun giorno e l’incasso totale in dollari per gli ordini di quel giorno:
Tooltip
Il nostro grafico appare bello ora, ma le informazioni nel tooltip mostrato quando il puntatore del mouse passa sopra un punto in esso potrebbero essere migliorate. Possiamo farlo aggiungendo una opzione tooltip
al codice del grafico:
$(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 %> }] }); });
L’opzione tooltip
ha a sua volta una propria opzione formatter
, che accetta una funzione per argomento. Questa funzione deve restituire una stringa che apparirà come messaggio nel tooltip. I valori per il punto possono essere formattati usando i formatter che fornisce Highcharts. Ne usiamo due di questi nel codice riportato sotto, per formattare il valore dei dati sull’asse X e i valori numerici sull’Y:
Possiamo ora ricaricare nuovamente la pagina e passare col mouse sopra i punti del grafico: vedremo il nostro tooltip ben formattato:
Mostrare serie multiple
Sistemato il grafico, ora è più immediato riconoscere il trend dei dati. Dando un’occhiata al grafico di sopra, posssiamo notare come ci sia stato un incremento degli ordini dal 19 luglio. Per aiutarci a capire che cosa ha causato questo incremento nelle vendite, sostituiamo la serie che attualmente mostra solamente l’andamento delle vendite totali, con due serie che mostrano, rispettivamente, le vendite da negozio (quelle con il valore del campo shipping
a false
) e le vendite vere e proprie.
Avremo bisogno di poter distinguere i due tipi di vendite, per cui la nostra prima modifica sarà quella di aggiungere due scope alla nostra classe Order
, in modo tale che possiamo in seguito ottenerli in modo semplice:
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
Tornando al JavaScript, nella vista degli ordini ora abbiamo un’altra opzione series
, in modo tale che sia tracciata anche un’altro insieme di dati. Per aiutarci a distinguere le due serie, diamo a ciascuna anche un nome:
$(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 %> }] }); });
Ora ci sono due serie e abbiamo utilizzato gli opportuni named scope in ciascuna di esse per ottenere i dati degli ordini significativi per esse per ogni giorno. Il grafico ora mostrerà entrambe le serie, con i loro nomi mostrati nella legenda di sotto:
Ora possiamo vedere i totali degli ordini scaricati e di quelli spediti separatamente e notare che il picco degli ordini negli ultimi giorni sta aumentando per entrambi i tipi di vendita. Si noti che quando un grafico mostra più di una serie, si può cliccare sul nome di ciascuna serie nella legenda per far sì, che questa sia nascosta o meno.
Rimozione delle duplicazioni
L’aggiunta di una seconda serie al grafico ha introdotto alcune duplicazioni nel codice JavaScript e se avessimo aggiunto ancor più serie, questa duplicazione sarebbe stata ancor maggiore. Possiamo ridurre il codice replicato usando un po’ di codice Ruby per creare dinamicamente il JavaScript di ciascuna 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 %>] }); });
Anzichè definire individualmente ciascuna serie, abbiamo ora un hash che definisce il nome di ogni serie ed il codice che serve a ottenere gli ordini per quelle serie. Possiamo poi iterare attraverso l’hash e generare il JavaScript per tale serie.
Ottimizzazione della query
Il grafico ora mostra le informazioni che volevamo vedere, ma l’SQL usato per ottenere i dati è piuttosto inefficiente. I log di sviluppo mostrano che viene eseguita una query diversa per ogni nodo del grafico:
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')
Possiamo migliorare ciò facendo in modo che sia fatta un’unica chiamata per serie al DB, usando i metodi group
e select
. Vogliamo recuperare gli ordini dal modello Order
e raggrupparli per purchase_date
e per ciascun gruppo di ordini restituito, vogliamo la data ed il prezzo totale. Il seguente codice fa proprio questo:
Order.group("date(purchased_at)").select("purchased_at, sum(total_price) as total_price")
Possiamo verificarlo in console, prendendo il total_price
del primo elemento restituito dalla query:
> Order.group("date(purchased_at)").select("purchased_at, sum(total_price) as total_price").first.total_price.to_f => 403.0
Tuttavia c’è un piccolo problema in questo approccio. Se non ci fossero ordini per una certa data, avremmo un punto mancante nei dati, per cui tutti i punti successivi sarebbero erroneamente anticipati di un giorno nel grafico. Dobbiamo tenere in considerazione questa possibilità, che renderà il codice un po’ più complicato. Per mantenere il codice pulito, spostiamo questa logica in un metodo 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 %>] }); });
Il codice erb che recupera i dati è ora stato sostituito da una chiamata al metodo orders_chart_series
, che accetta come argomento lo scope di interesse relativo al modello Order
e la data iniziale per le serie. Scriviamo questo nuovo metodo nel module 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
Al metodo orders_chart_series
passiamo lo scope orders
e recuperiamo così gli ordini che corrispondono all’intervallo cronologico che va da start_time
alla fine della data odierna. Poi usiamo group per raggruppare i risultati per giorno e poi selezioniamo la data ed il totale degli ordini per quel giorno. Infine iteriamo fra le date nell’intervallo e prendiamo il totale degli ordini per quel giorno, sostituendo con 0
se non risultano ordini.
Al ricaricamento della pagina, adesso, guardando i log potremo notare che il numero delle chiamate al database fatte per ciascuna richiesta si è ridotto a due:
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)
Alternative
Highcharts è una libreria per la creazione di grafici davvero molto buona, ma potrebbe non soddisfare esattamente le vostre esigenze, per cui chiudiamo questo episodio con una rapida carrellata su alcune alternative.
Il primo di questi è Flot. Anche questo usa JavaScript e può essere usato per creare dei grafici davvero meravigliosi.
Un’altra libreria JavaScript è gRraphaël. E’ ottima per produrre grafici a torta e a barre, per cui se dovete produrre qualcosa del genere, dateci uno sguardo.
Infine c’è Bluff. Si basa sulla libreria Ruby Gruff. E’ una soluzione semplice e carina, per cui se state cercando qualcosa di leggero, può valere la pena di darci un’occhiata.
E’ tutto per questo episodio. Se la vostra applicazione avrà bisogno di mostrare dei grafici, ora avete un bel po’ di alternative fra cui scegliere.