rolisz's site

Grafice cu d3.js - part 2

Data trecută am făcut un barchart simplu, care să poată fi sortat. Acum, să îl complicăm un pic: vrem să prezentăm un set de date, care este împărțit pe mai multe grupe, datele având mai multe dimensiuni. Vrem să vedem cum se compară grupele diferite la diferite dimensiuni. Vom reprezenta datele din fiecare dimensiune în câte un barchart, separat pentru fiecare grupă sau mai multe împreună, schimbarea făcându-se alegând grupele care să apară. Când schimbăm în cadrul aceleiași grupe, indivizii vor avea per­sis­tență, adică car­ac­ter­is­ti­ca din a doua dimensiune va fi reprezen­tată în locul unde a fost reprezen­tată car­ac­ter­is­ti­ca core­spun­ză­toare primei dimensiuni.

În HTML vom pune butoanele pentru sortare și filtrare. Nothing fancy.

<button id="sort">
Sort

</button>
</p>
<select id="grupa" multiple> <option value="Grupa 1" selected>Grupa
1</option> <option value="Grupa 2" selected>Grupa 2</option>
<option value="Grupa 3" selected>Grupa 3</option> </select>
<select id="dim"> <option value="dim1">Dimensiunea 1</option>
<option value="dim2">Dimensiunea 2</option> </select>

Setul de date pe care îl vom reprezenta în acest tutorial va conține 3 grupe și 2 dimensiuni, pentru simplitate, dar poate fi ușor extins. (3 grupe din anul meu cu notele la 2 materii)

var data = {"Grupa 1":
[{"dim1":6,"dim2":6},{"dim1":10,"dim2":10},{"dim1":3,"dim2":4},{"dim1":9,"dim2":8},{"dim1":0,

"dim2":6},{"dim1":4,"dim2":5},{"dim1":5,"dim2":5},{"dim1":6,"dim2":8},{"dim1":8,"dim2":7},{"dim1":0,"dim2":4},

{"dim1":5,"dim2":7},{"dim1":9,"dim2":9},{"dim1":0,"dim2":4},{"dim1":6,"dim2":5},{"dim1":3,"dim2":4},{"dim1":10,

"dim2":6},{"dim1":7,"dim2":4},{"dim1":7,"dim2":9},{"dim1":9,"dim2":10},{"dim1":0,"dim2":4},{"dim1":9,

"dim2":5},{"dim1":8,"dim2":7},{"dim1":7,"dim2":8},{"dim1":5,"dim2":7},{"dim1":0,"dim2":4},{"dim1":7,
"dim2":7},{"dim1":4,"dim2":0}],
"Grupa 2":
[{"dim1":4,"dim2":6},{"dim1":5,"dim2":4},{"dim1":5,"dim2":0},{"dim1":7,"dim2":6},{"dim1":6,"dim2":6},

{"dim1":9,"dim2":8},{"dim1":7,"dim2":9},{"dim1":9,"dim2":7},{"dim1":10,"dim2":10},{"dim1":7,"dim2":8},

{"dim1":8,"dim2":6},{"dim1":5,"dim2":7},{"dim1":5,"dim2":6},{"dim1":6,"dim2":10},{"dim1":3,"dim2":0},

{"dim1":7,"dim2":7},{"dim1":7,"dim2":6},{"dim1":6,"dim2":6},{"dim1":0,"dim2":6},{"dim1":4,"dim2":0},
{"dim1":0,"dim2":4},{"dim1":4,"dim2":4},{"dim1":0,"dim2":4}],
"Grupa 3":
[{"dim1":6,"dim2":7},{"dim1":4,"dim2":6},{"dim1":6,"dim2":4},{"dim1":0,"dim2":4},{"dim1":6,"dim2":6},

{"dim1":6,"dim2":5},{"dim1":7,"dim2":7},{"dim1":4,"dim2":4},{"dim1":0,"dim2":6},{"dim1":7,"dim2":8},

{"dim1":8,"dim2":7},{"dim1":4,"dim2":5},{"dim1":4,"dim2":7},{"dim1":5,"dim2":4},{"dim1":7,"dim2":8},

{"dim1":4,"dim2":6},{"dim1":5,"dim2":5},{"dim1":9,"dim2":4},{"dim1":0,"dim2":4},{"dim1":6,"dim2":4},
{"dim1":4,"dim2":6},{"dim1":6,"dim2":4},{"dim1":0,"dim2":4}]}

Cum datele noastre nu mai sunt într-un simplu array, ci sunt grupate, iar d3.js lucrează în principal cu array-uri, va trebui să punem într-un array datele care ne in­tere­sează.

var note = data["Grupa 1"].concat(data["Grupa 2"]).
        concat(data["Grupa 3"])
var accesor = function(d) { return d.dim1 }
l = note.length
for (var i = 0; i< l; i++) {
    note[i].index = i;
}

La început, lucrăm cu toate datele. Cum datele noastre sunt sub forma unui obiect, trebuie să îi zicem la d3.js care din atributele acestor obiecte să le folosească pentru valori. Așa că definim o funcție de accesare care pentru parametrul de intrare returnează prima dimensiune. De asemenea, cum avem multe date care nu sunt tot timpul afișate, ca d3.js să poată face core­spon­dența între ce este deja afișat și ce trebuie adăugat sau scos, adăugăm un index fiecărui obiect.

Urmează apoi creare scalelor, a axelor și a con­taineru­lui SVG. Nu îl mai postez aici pentru că e la fel cu celălalt. Și acum începe iar distracția. Pentru că avem multe date, s-ar putea să nu încapă pe ecran și va trebui să facem scrolling. Pentru că SVG îi... nașpa, trebuie să introducem un element în plus care să prindă eveni­mentele de mouse (sau touch :-" ).

var z = d3.behavior.zoom().scaleExtent([1,1])
        .on("zoom", zoom)
svg.append("rect")
      .attr("class","panning")
      .attr("fill","white")
      .attr("width",w)
      .attr("height",h)
      .attr("pointer-events","all")
      .call(z)

d3.js are integrată ca­pa­bil­i­tatea de a prelucra eveni­mentele de mouse, atât ca și mișcare ca și scrolling. Acestea sunt obținute prin „behavior”-ul zoom, care cheamă un callback în care avem acces la datele despre eveniment, cum ar fi cât s-a deplasat mouseul și cât s-a scrolluit. Cum noi nu avem nevoie de red­i­men­sion­are, fixăm scara la 1 și apoi aplicăm această funcție drep­tunghi­u­lui creat special pentru a prinde eveni­mentele de trans­latare.

function zoom() {
     var dx = d3.event.translate[0];

    var max = d3.max(x.range()) - w + 40
    if (max \< 0) {
    return
    }
    if (dx \> 0) {
    dx = 0
    }
    if (dx \< -max) {
    dx = -max
    }
    scrollTo(dx)
}

function scrollTo(dx) {
    z.translate([ dx, 0])
    rect.attr("transform", "translate(" + dx + ",0)")
    svg.select(".x.axis").attr("transform", "translate(" + dx + ")")
}

Partea de trans­latare este împărțită în două părți: una de calcul al valorii la panning și una de mișcare (care este refolosită altundeva). Deplasarea (absolută) pe X o obținem din d3.event.trans­late[0], iar apoi verificăm să nu fi ajuns în afara limitelor (înainte sau după grafic). În scrollTo facem mișcarea graficului și a axelor și setăm și valoarea pe care o ține minte d3.js legată de trans­latare (pentru cazul în care am ajuns la limită, să nu țină el minte că a trecut mai încolo).

 var rect = svg.append("svg")
            .attr("class","container")
            .attr("viewbox","0 0 "+ w + " " + h)
            .selectAll("rect")
            .data(note,function(d) {return d.index})

Când creăm con­tainerul pentru bare, față de data trecută trebuie să specificăm limitele între care se poate vedea prin el, pentru ca atunci când facem scroll să nu fie elemente puse aiurea, de exemplu înainte de axa verticală.

Față de data trecută, acum nu mai facem nimica direct cu datele, ci punem totul într-o funcție, pentru că mai încolo vom refolosi aceasta când schimbăm care date le afișăm.

function addData() {
     var newElems = rect.enter().append("rect")
                         .attr("pointer-events","none")
                         .attr("x",function(d,i) { return x(i) })
                         .attr("width", x.rangeBand())
                         .attr("y",h)
                         .attr("height",0)
     modify(newElems)
}

function modify(elems) {
    elems.transition()
        .duration(1000)
        .attr("height",function(d) { return h - y(accesor(d))} )
        .attr("y",function(d) { return y(accesor(d))})
        .attr("x",function(d,i) { return x(i) })
        .style("fill",function(d) {
            if (accesor(d) == Math.ceil(d3.median(note,accesor)))
                return "\#81642A"
            else if ( accesor(d) \< 5)
                return "\#d62728";
            else
                return "\#2ca02c"
            })
}

Funcția addData se ocupă strict de adăugarea el­e­mentelor noi la grafic (cam la fel ca data trecută, doar că mai adăugăm o pro­pri­etate legată de mouse clicks), pe când modify aplică tranziția de poz­iționare și di­men­sion­are la elementele date ca parametru - dacă e apelată din addData, atunci o aplică el­e­mentelor noi, iar mai încolo o vom folosi la schimbarea între dimensiuni. În rest e la fel cu ce am făcut data trecută.

function removeData() {
    rect.exit()
          .transition()
          .attr("y",h)
          .attr("height",0)
}

Aici trebuie să dau câteva explicații. Ați observat până acuma funcția enter imediat după data, iar acum apare și exit. Ce sunt acestea? Well, în d3.js există conceptul de join (nu ca cele din SQL :)) ) care specifică cum se îmbină două seturi de date. Stări posibile
la reuniunea de date. Thinking with
joins Stări posibile la reuniunea de date. Thinking with joins

În momentul în care legăm date de o selecție d3.js, ele sunt împărțite în 3 părți: enter, pentru datele noi, modify pentru datele care au același index (sau funcție de indexare), și exit pentru datele care nu se mai regăsesc între cele noi. Când adăugăm pentru prima dată date, toate intră în enter. Dar când le schimbăm, pot apărea toate cazurile. În cazul nostru noi ștergem elementele cu o animație scurtă.

d3.selectAll("#dim").on('change',function(){
     var materie = this.value
     accesor = function(d) { return d[materie]}
     rect = rect.data(note,function(d) {return d.index})
     modify(rect)
})

d3.select("#grupa").on('change',function() {
note = []
var options = this.options;
var opt;
for (var i=0, iLen=options.length; i<ilen; i++) {<br></ilen;> opt =
options[i];

if (opt.selected) {
note = note.concat(data[opt.value]);
}
}
index = d3.range(note.length)
setAxis()
rect = rect.data(note,function(d) {return d.index})
scrollTo(0)
modify(rect)
removeData()
addData()
})

Și acum să tratăm schim­bările de date. Dacă se schimbă di­men­si­unea pe care vrem să o afișăm (schimbăm materia) atunci trebuie să schimbăm funcția accesor, ca această să returneze materia nouă. Legăm datele din nou de selecția și apoi aplicăm funcția de modificare, descrisă mai sus.

Când schimbăm grupele, parcurgem selectul ca să vedem care sunt selectate și le concatenăm notele. Resetăm axele (probabil avem alt număr de date) și indexul, legăm datele noi, mergem la poziția 0 (s-ar putea să fi fost la sfârșitul unei chartului și dacă nu am mai avea suficiente bare și după schimbare să ne trezim cu un ecran alb) și apoi aplicăm funcțiile de modificare, ștergere și adăugare.

Și în mare... aceasta ar fi. Sortarea e la fel ca data trecută. Și cu asta avem un grafic destul de drăguț (în opinia mea) cu care putem să explorăm datele noastre mul­ti­di­men­sion­ale și mul­ti­cat­e­gor­i­cale.

Totul poate fi văzut la un loc aici.