Questo è il terzo di una serie di articoli che illustrano come sviluppare un dendrogramma, utilizzando la libreria JavaScript D3, costruito in base ad una particolare struttura dati contenuta all’interno di un file JSON.
Leggi l’articolo:
- Come realizzare un dendrogramma con la libreria D3 (parte 1)
- Come realizzare un dendrogramma con la libreria D3 (parte 2)
Nell’articolo precedente abbiamo realizzato una semplice struttura ad albero.
In questo articolo, amplieremo l’esempio precedente convertendolo in un vero e proprio dendrogramma, in cui terremo conto anche della distanza ultrametrica tra i vari nodi.
Dalla figura 2, infatti possiamo notare che i due nodi parent non sono più allineati tra di loro anche se appartengono allo stesso livello (livello 1). La posizione dei due nodi parenti non è casuale, ma è posizionata in corrispondenza del valore distanza, scalato sull’asse riportato nella parte superiore della figura.
Nel nostro esempio imporremo che la distanza tra tutte le foglie e la radice sia di 100, e che le distanze tra le foglie e i due nodi di dipartizione siano, rispettivamente, di 76 per il nodo A e di 30 per il nodo B.
Cominciamo quindi a definire una nuova struttura dati in un file JSON, salvandolo come dendrogram02.json. Questo file verrà letto dalla libreria D3 e i dati contenuti all’interno verranno interpretati per generare un dendrogramma.
{ "Name": "Root", "Y": 0,
"Children": [
{ "name": "parent A",
"y" : 30,
"children": [
{"name": "child A1", "y" : 100},
{"name": "child A2", "y" : 100},
{"name": "child A3", "y" : 100}
]
},{ "Name": "Parent B", "Y": 76,
"Children": [
{"name": "child B1", "y" : 100},
{"name": "child B2", "y" : 100}
]
}
]
}
Adesso che abbiamo definito la struttura possiamo scrivere la pagina Web che rappresenterà il nostro dendrogramma, salvandola come dendrogramma02.html.
<!doctype html>
<html>
<head>
<style>
.node circle {
fill: #fff;
stroke: steelblue;
stroke-width: 1.5px;
}
.node {
font: 20px sans-serif;
}
.link {
fill: none;
stroke: #ccc;
stroke-width: 1.5px;
}
line { stroke: black; }
</style>
<script type="text/javascript" src="http://d3js.org/d3.v3.min.js"></script>
</head>
<body>
<script type="text/javascript">
var width = 600;
var height = 500;
var cluster = d3.layout.cluster()
.size([height, width-200]);
var diagonal = d3.svg.diagonal()
.projection (function(d) { return [x(d.y), d.x];});
var svg = d3.select("body").append("svg")
.attr("width",width)
.attr("height",height)
.append("g")
.attr("transform","translate(100,0)");
var xs = [];
var ys = [];
function getXYfromJSONTree(node){
xs.push(node.x);
ys.push(node.y);
if(typeof node.children != 'undefined'){
for ( j in node.children){ getXYfromJSONTree(node.children[j]);
}
}
}
var ymax = Number.MIN_VALUE;
var ymin = Number.MAX_VALUE;
d3.json("dendrogram02.json", function(error, json){
getXYfromJSONTree(json);
var nodes = cluster.nodes(json);
var links = cluster.links(nodes);
nodes.forEach(
function(d,i){
if(typeof xs[i] != 'undefined')
{ d.x = xs[i]; }
if(typeof ys[i] != 'undefined')
{ d.y = ys[i]; } var xs = [];
var ys = [];
function getXYfromJSONTree(node){
xs.push(node.x);
ys.push(node.y);
if(typeof node.children != 'undefined'){
for ( j in node.children){
getXYfromJSONTree(node.children[j]);
}
}
}
});
nodes.forEach(
function(d){
if(d.y > ymax) ymax = d.y;
if(d.y < ymin) ymin = d.y;
});
x = d3.scale.linear().domain([ymin, ymax]).range([0, width-200]);
xinv = d3.scale.linear().domain([ymax, ymin]).range([0, width-200]);
var link = svg.selectAll(".link")
.data(links)
.enter().append("path")
.attr("class","link")
.attr("d", diagonal);
var node = svg.selectAll(".node")
.data(nodes)
.enter().append("g")
.attr("class","node")
.attr("transform", function(d) { return "translate(" + x(d.y) + "," + d.x + ")"; });
node.append("circle")
.attr("r", 4.5);
node.append("text")
.attr("dx", function(d) { return d.children ? -8 : 8; }) .attr("dy", 3)
.style("text-anchor", function(d) { return d.children ? "end" : "start"; })
.text( function(d){ return d.name;});
var g = d3.select("svg").append("g")
.attr("transform","translate(100,40)");
g.append("line")
.attr("x1",x(ymin))
.attr("y1",0)
.attr("x2",x(ymax))
.attr("y2",0);
g.selectAll(".ticks")
.data(x.ticks(5))
.enter().append("line")
.attr("class","ticks")
.attr("x1", function(d) { return xinv(d); })
.attr("y1", -5)
.attr("x2", function(d) {return xinv(d); })
.attr("y2", 5);
g.selectAll(".label")
.data(x.ticks(5))
.enter().append("text")
.attr("class","label")
.text(String)
.attr("x", function(d) {return xinv(d); })
.attr("y", -5)
.attr("text-anchor","middle");
});
</script>
</body>
</html>
Adesso commenteremo le parti del codice che abbiamo aggiunto/modificato rispetto all’esempio precedente.
Nell’esempio precedente abbiamo visto come passando direttamente la struttura json contenuta nel file alla funzione cluster.nodes(), quest’ultima leggeva i valori degli attributi name e children specificati nel file assegnandoli in maniera automatica agli attributi interni di ogni singolo object node generato. Per quanto riguarda invece gli attributi x e y, la funzione ne calcola il valore in maniera automatica, facendo in modo che le coordinate assegnate a ciascun nodo formino una struttura ad albero ordinata. Ma questa funzionalità a noi non interessa, anzi, per definire un dendrogramma abbiamo bisogno che il codice in qualche modo legga i valori delle distanze (attributo y) da assegnare a ciascun nodo nel modo in cui le abbiamo definite all’interno del file JSON.
Quindi sarà necessario definire una funzione che svolga questo lavoro. Definiamo due vettori xs e ys che contengano i valori di x e y letti all’interno del file (praticamente svolgono funzioni simili a gli array nodes e links). Poi implementiamo una funzione, che chiameremo getXYfromJSONTree().
var xs = [];
var ys = [];
function getXYfromJSONTree(node){
xs.push(node.x);
ys.push(node.y);
if(typeof node.children != 'undefined'){
for ( j in node.children){
getXYfromJSONTree(node.children[j]);
}
}
}
Se notate bene, questa è una funzione ricorsiva (cioè chiama se stessa al suo interno). Questa funzione svolge una funzione simile a quella che svolgono cluster.nodes() e cluster.links() solamente che invece di nodi e link, si occuperà di leggere i valori x e y contenuti nella struttura JSON. Come la funzione cluster.nodes() si passa come argomento il nodo radice della struttura e poi ricorsivamente controlla nodo per nodo (seguendo la stessa sequenza di lettura di cluster.nodes()) per generare una sequenza di valori x,y nello stesso ordine della sequenza dei nodi contenuti nell’array nodes. E’ molto importante che venga rispettato questo ordine, per dare una giusta corrispondenza tra i nodi contenuti nell’array e le coordinate (x,y).
Se all’interno della struttura JSON non abbiamo specificato alcun valore per x e/o per y, la funzione getXYfromJSONTree() non assegnerà alcun valore all’interno dell’elemento dell’array, lasciando il campo “undefined“. Successivamente gestiremo tali valori indefiniti, considerando, in questi casi, valido il valore calcolato automaticamente da cluster.nodes().
Introducendo i valori delle distanze, stiamo introducendo una scala di valori, su cui poi dovremo definire un dominio ed un range. Ma per fare questo è necessario conoscere, appunto, l’intervallo di estensione dei valori definiti all’interno del file JSON e soprattutto è necessario farlo in maniera dinamica (cioè questo intervallo varierà a seconda dei valori contenuti nel file e la pagina web deve essere in grado di gestire ogni diversa eventualità). Quindi definiamo due variabili ymax e ymin, che ci consentiranno di stabilire gli estremi di questo intervallo.
var ymax = Number.MIN_VALUE;
var ymin = Number.MAX_VALUE;
Avrete certamente notato che a ymax abbiamo il minore valore possibile gestibile da JavaScript, mentre per ymin abbiamo fatto l’opposto, assegnandogli il valore maggiore possibile gestibile da JavaScript. Questo perchè nelle iterazioni necessarie per stabilire i valori di massimo e di minimo contenuti all’interno della struttura JSON, sarà necessario partire dai valori estremi opposti.
All’interno della funzione d3.json() aggiungiamo la funzione getXYfromJSONTree() alle altre due funzioni cluster.nodes() e cluster.links(), in modo da leggere anche gli attributi x e y.
d3.json("dendrogram02.json", function(error, root){
getXYfromJSONTree(root);
var nodes = cluster.nodes(root);
var links = clister.links(nodes);
Una volta definiti tutti gli elementi della struttura è il momento di sovrascrivere tutti gli attributi x e y all’interno dell’array nodes (tutti eccetto quelli ‘undefined‘).
nodes.forEach(
function(d,i){
if(typeof xs[i] != 'undefined'){
d.x = xs[i];
}
if(typeof ys[i] != 'undefined'){
d.y = ys[i];
}
});
Adesso è il momento di determinare l’intervallo dei valori delle distanze, e quindi facciamo una scansione all’internod id nodes per ricavarci i valori ymin e ymax.
nodes.forEach(
function(d){
if(d.y > ymax)
ymax = d.y;
if(d.y < ymin)
ymin = d.y;
});
Adesso definiamo la scala delle distanze x (asse x), definendone il dominio e il range. Ricordo che il dominio è l’estensione dei valori contenuti all’interno della struttura mentre il range è l’intervallo (in pixel) dell’area di disegno dove verrà riportata la scala. Definiamo anche una scala xinv che è perfettamente l’opposta di quella x, cioè stessa scala, ma in direzione contraria.
x = d3.scale.linear().domain([ymin, ymax]).range([0, width-200]);
xinv = d3.scale.linear().domain([ymax, ymin]).range([0, width-200]);
Ricordiamoci di modificare i valori di d.y all’interno del codice in base alla scala appena definita.
var node = svg.selectAll(".node")
.data(nodes)
.enter().append("g")
.attr("class","node")
.attr("transform", function(d) { return "translate(" + x(d.y) + "," + d.x + ")"; });
Ed infine concludiamo il tutto rappresentando nella parte superiore dell’area di disegno l’asse riportante la scala x.
var g = d3.select("svg").append("g").attr("transform","translate(100,40)");
g.append("line")
.attr("x1",x(ymin))
.attr("y1",0)
.attr("x2",x(ymax))
.attr("y2",0);
g.selectAll(".ticks")
.data(x.ticks(5))
.enter().append("line")
.attr("class","ticks")
.attr("x1", function(d) { return xinv(d); })
.attr("y1", -5)
.attr("x2", function(d) {return xinv(d); })
.attr("y2", 5);
g.selectAll(".label")
.data(x.ticks(5))
.enter().append("text")
.attr("class","label")
.text(String)
.attr("x", function(d) {return xinv(d); })
.attr("y", -5)
.attr("text-anchor","middle");
Con questo abbiamo concluso il terzo articolo.
Nel prossimo articolo vedremo come aggiungere una ulteriore distanza: quella che intercorre tra le foglie. Implementeremo il codice del dendrogramma in modo da gestire anche i valori x definiti all’interno della struttura JSON.
Come realizzare un dendrogramma con la libreria D3 (parte 4)