rolisz's site

Grafice cu d3.js - part 1

Anul trecut am făcut graficele cu notele din sesiune în R. Au fost super simplu de făcut, mai am scrip­turile de generare, și probabil aș putea să le refolosesc și să termin cu toată treaba în 5 minute. Dar... arată un pic cam nașpa. Liniile nu au an­tialias­ing, culorile sunt cam stridente și, cel mai important, sunt statice, nu se poate in­ter­acționa cu ele.

Așa că am decis ca anul acesta să le refac în d3.js, care îi la modă acuma pentru vizual­izări de date. După cum sugerează și numele, acesta este o librărie Javascript care se folosește la ma­nip­u­larea doc­u­mentelor în funcția de datele pe care le avem. Se bazează pe HTML5, CSS3 și SVG, deci e complet open.

Cum d3.js este relativ low-level, nu oferă din start posi­bil­i­tatea de a crea grafice, ci noi trebuie să le facem, în schimb oferă o flex­i­bil­i­tate ex­tra­or­di­nară.

În acest tutorial vom face un bar chart care să prezinte un set de date, să poată fi sortat (după valorile din grafic), iar barele să aibă culori diferite în funcție de diferite nivele ale valorilor.

Vom reprezenta următorul set de date [4, 5, 5, 7, 6, 9, 7, 9, 10, 7, 0, 0, 8, 5, 5, 6, 3, 7, 7, 6, 0, 0, 4, 0, 0, 4, 0] coughnotele grupei mele la Prob­a­bil­ități și statisticăcough, unde 0-urile corespund acelor indivizi pentru care nu avem informații (au lipsit de la examen).

Partea de HTML a proiec­tu­lui este... doar un schelet de bază, fix cât să fie valid și să includă scriptul d3.js și propriul nostru script (inline sau într-un fișier separat, e la alegerea voastră). Mai întâi să definim mărimea graficului nostru:

var margin = { top: 20, right: 10, bottom: 20, left: 30 };
var width = window.innerWidth - 20 - margin.right - margin.left;
var height = 500 - margin.top - margin.bottom;`{lang="js"}

Lățimea va fi cât ecranul, mai puțin o mică bordură, iar înălțimea va fi de 500 pixeli.

var index = d3.range(note.length)
var x = d3.scale.ordinal().domain(index).rangeBands([0,note.length * 25],0.08)

var y = d3.scale.linear()
.domain([0,d3.max(note)])
.range([height,0])

Variabila index va conține indicii asociați fiecărei valori. Funcția range returnează ca în Python, un array care conține toate numerele de la 0 până la primul parametru (sau se poate da și parametrul start și un pas).

La ce folosesc cele două variabile de tip scale? În general, valorile care se folosesc în grafice pot avea valori arbitrare, așa că nu pot fi reprezen­tate în mod direct pe ecran, pentru că e cam greu să îngh­e­suiești o coloană de 6000 unități arbitrare de măsură pe un ecran de 1000px înălțime, ci trebuiesc să fie red­i­men­sion­ate. Obiectele de tip scale sunt folosite chiar pentru aceasta: dau o core­spon­dență între două domenii de valori. Sunt mai multe tipuri de scalări, cum ar fi scală liniară (care folosește o funcție liniară de forma $ f(x) = mx + b $ pentru a da noua valoare), scală poli­no­mi­ală (care folosește o funcție de forma $ f(x) = mx^k + b $), scale ordinale, care asociază un număr de ordine el­e­mentelor din domeniu, și chiar și scale de culoare, care asociază culori unor categorii distincte de elemente (și culorile sunt alese cu grijă să aibă contrast bun unele față de altele și să se potrivească bine în același grafic).

Noi folosim două scalări: una pentru indicele el­e­mentelor și una pentru valorile notelor. Prima va fi folosită pentru a poziționa barele la locurile potrivite (constanta magică 25 reprezintă grosimea unei coloane, iar 0.08 este distanța (relativă) între două elemente con­sec­u­tive). A doua scală are domeniul între 0 și maximul dintre note și corespunde unei valori de maxim înălțimea graficului și minim 0. Atenție mare, contează ordinea în care sunt date minimul și maximul. Cum am dat noi, mai întâi maximul, va face ca la 10 să îi corespundă o înălțime de 0px, iar la 0 înălțimea maximă a graficului. De ce am făcut așa? Pentru că în SVG sistemele de coordonate nu sunt așa cum le-am învățat noi la școală (axa y crește în sus, axa x crește la dreapta), ci axa y crește în jos. Voi explica mai detaliat când ajungem la partea de desenare a barelor.

Acum ajungem la părțile faine: ma­nip­u­larea DOM-ului.

var svg = d3.select("body").append("svg")
                           .attr("class","chart")
                           .attr("width", width + margin.right + margin.left)
                           .attr("height", height + margin.top + margin.bottom)
                           .append("g")
                           .attr("transform","translate("+margin.left + "," + margin.top + ")")

Selectarea de elemente se face într-un mod foarte familiar pentru cei care au mai lucrat cu jQuery: cu selectori CSS. Funcția select returnează doar primul element găsit, pe când selectAll returnează un array cu toate elementele găsite. Schimbarea și obținerea valorii atributelor se face tot ca și în jQuery, cu funcția attr, care dacă are un singur parametru returnează valoarea curentă, și cu doi parametrii schimbă valoarea. Ma­jori­tatea metodelor returnează elementul pe care au acționat, astfel putând fi înlănțuite mai multe operații de modificare una după alta.

Noi adăugăm un element SVG la corpul doc­u­men­tu­lui nostru, îi setăm clasa și di­men­si­u­nile, iar apoi îi adăugăm un element g. Acest element are același rol în SVG ca și div-ul în HTML: să grupeze elemente la un loc. Alte elemente SVG au atribute x și y cu care pot fi poz­ițion­ate, dar g nu are așa ceva (nu știu de ce), așa că trebuie să îl poziționăm acționând cu o trans­for­mare asupra lui: îl translatăm pur și simplu la poziția dorită.

var rect = svg.selectAll("rect")
              .data(note)
              .enter().append("rect")
              .attr("x",function(d,i) { return x(i) })
              .attr("width", x.rangeBand())
              .attr("y",height)
              .attr("height",0)

Acum am început să creăm coloanele. Cum încă nu avem niciun element rect, selectAll ne va returna selecția vidă. Și aici intervine geniul d3.js. Legăm de această selecție datele noastre și apoi spunem ce acțiuni trebuie făcute pentru un element al datelor noastre. Nu trebuie să iterăm peste toate datele noastre, este destul să spunem în mod generic ce trebuie să se întâmple pentru elementul cu valoare d de pe poziția i (aceștia sunt parametrii pe care îi primesc funcțiile noastre). În cazul nostru, pentru fiecare notă creăm un dreptunghi, pe care îl punem la poziția orizontală core­spun­ză­toare indicelui său (poziția o calculăm folosind scala definită mai sus), lățimea o scoatem tot de la scară. La di­men­si­u­nile pe verticală vom face o mică animație, așa că la început vom poziționa elementul în partea de jos a graficului (mai țineți minte, axa verticală este invers în SVG) și îi setăm înălțimea să fie 0.

rect.transition()
        .duration(1000)
        .attr("y",y)
        .style("fill",function(d) {
             if (d == Math.ceil(d3.median(note)))
               return "#8c564b"
             else if (d < 5)
               return "#d62728";
             else
               return "#2ca02c"
           })
       .attr("height",function(d) { return height - y(d)} )

Let's make it pretty :D În jargonul d3.js, tranz­iți­ile sunt animații la care putem defini doar starea de început și sfârșit, de in­ter­po­lare ocupându-se librăria. În animația noastră, care durează 1 secundă, mutăm drep­tunghi­ul nostru din poziția inițială, de jos, până la înălțimea dată de scala verticală (care este inversă!) și în același timp lungindu-l până va ajunge până jos. Care este efectul? Aparent drep­tunghi­ul va crește. De asemenea, mai colorăm drep­tunghi­ul (d3.js face in­ter­polări și la culori :> ) astfel încât notele care sunt la mijlocul grupei să fie maro, cele care sunt de trecere să fie verzi, iar cele care au picat să fie roșii.

Niciun grafic nu este complet fără a avea axe. d3.js ne poate ajuta și aici. Este suficient să îi dăm mărimile din graficul nostru și el va genera automat pozițiile la care trebuie să pună numerele core­spun­ză­toare.

var xAxis = d3.svg.axis().scale(x)
             .tickSize(1)
             .tickPadding(6)
             .orient("bottom")

svg.append("g")
.attr("class","x axis")
.attr("transform","translate(0," + ( h ) + ")")
.call(xAxis)

var yAxis = d3.svg.axis().scale(y)
.tickSize(1)
.tickPadding(6)
.orient("left")

svg.append("g")
.attr("class","y axis")
.call(yAxis)

Fiecărui obiect de tip axis îi dăm scala core­spun­ză­toare, setăm grosimea și marginea liniei și specificăm orientarea ei. Apoi, creăm un nou element care să conține axa, îl poziționăm unde trebuie și aplicăm axa acelui element.

Cum notele sunt în ordine aproape aleatoare, nu ne putem da seama doar aruncând un ochi câte note sunt de un anumit tip. Ne-ar fi mai util dacă ar fi sortate. Hai să le sortăm apăsând un buton.

sort = false;
d3.select("body").append("button")
        .property("innerHTML","Sort")
        .on("click",function() {
            if (sort = !sort) {
                index.sort(function(a,b) { return note[a] - note[b] })
             } else {
                 index = d3.range(note.length)
             })

x.domain(index)
xAxis.scale(x)
d3.select(".x.axis").transition()
.duration(750)
.call(xAxis)

rect.transition()
.duration(750)
.delay(function(d,i) { return i \*50})
.attr("x",function(d,i) {return x(i)})
})

Ca să fie mai interesant, vom permite și „des­ortarea” :))) Asta înseamnă că alternăm între starea sortată și cea inițială. Sortarea trebuie aplicată indicilor, dar în funcție de valorile notelor. Valoarea nouă a indicilor o dăm scalei, schimbăm scala și în axa orizontală, iar apoi, cu o animație, mutăm fiecare element la poziția nouă. Pentru un efect și mai plăcut, fiecare element va porni cu o mică întârziere spre locul lui.

Toate acestea puse la un loc se pot vedea aici. Soon to follow, more cool d3.js stuff!