D3.js: Chord-Diagramm Teil 2 – Benutzerdefinierte Sortierung und Kurvenformen

1. Einleitung

Im ersten Teil ging es darum, aus einem Satz von Daten ein D3-Chord-Diagramm zu erstellen. In diesem zweiten Teil soll dieses Diagramm nun optimiert werden: Zuerst soll eine bessere Chord-Sortierung das Diagramm weniger chaotisch aussehen lassen, indem Überkreuzungen von Chords so weit wie möglich reduziert werden. Die Chords werden danach weiter verbessert, indem ihre Formen so verändert werden, dass sich benachbarte Chords nicht mehr überlappen. Durch den Einsatz von Gradienten bei der Färbung der Chords soll deren bidirektionale Natur verdeutlicht werden. Schliesslich soll das Diagramm interaktiv werden. Genauer sollen Tooltips angezeigt werden, wenn die Maus über die verschiedenen Teile des Diagramms bewegt wird. Bereiche sollen angeklickt werden können, um alle Chords, die nicht in Verbindung mit diesem Bereich stehen, auszublenden.

Zur besseren Veranschaulichung, hier noch einmal das angestrebte Endresultat:

Fertiges Diagramm (animiert)

2. Chord-Sortierung

Am Ende des ersten Teils haben wir die Chords sortiert, und zwar nach deren Quantität. Das bedeutet, dass bei jedem Ringsegment die Reihenfolge, in welcher die Chords am Ringsegment “befestigt” sind, davon abhängt, wie breit der Chord an diesem Ende ist. Bei einem Diagramm mit sehr vielen Chords ist es jedoch sinnvoll, die Chords nach “adjacency”, also nach Nachbarschaft zu sortieren. Am besten sieht man das, wenn man nur die Chords eines Bereiches einblendet:

Links wurde die aktuelle Sortierung verwendet: Die Chords werden nach Breite absteigend am Ringsegment sortiert. Rechts wurden die Chords so angeordnet, dass sich keine zwei Chords, welche am selben Ringsegment enden, überkreuzen (Sie überlappen sich hier teilweise noch, das beheben wir als nächstes). Das rechte Diagramm sieht weniger chaotisch aus und wird für diesen Fall deshalb vorgezogen.

Aber wie erreicht man diese Chord-Sortierung? Leider bietet D3 diese nicht als Option an, weshalb es hier notwendig ist, den Quellcode von D3 leicht anzupassen. Die Sortierung geschieht dabei im Chord-Layout-Generator in der Datei chord.js (Original: https://github.com/d3/d3/blob/v3.5.17/src/layout/chord.js). Für die Sortierung wird die Zeile

subgroupIndex.push(d3.range(n));

ersetzt durch

for (var m = 0; m < n; m++) {
	numSeq[m] = (n + (i - 1) - m) % n;
}
subgroupIndex.push(numSeq);

wobei das Array numSeq vorgängig noch initialisiert werden muss. Diese Formel bzw. Lösung für das Sortierungsproblem wurde von Nadieh Bremer von https://www.visualcinnamon.com/ entwickelt.

Um diese neue Sortierung einzusetzen muss nun nur noch die Zeile

var chord = d3.layout.chord()

ersetzt werden durch:

var chord = customChordLayout()

Resultat:

HTML-Datei mit besserer Sortierung: chord_diagram_sorted_by_adjacency

3. Benutzerdefinierte Kurvenformen

Was jetzt noch stört, ist, dass sich nebeneinanderliegende Chords zum Teil immer noch überlappen, obwohl sie sich nicht kreuzen. Dies lässt sich sicherlich auch noch verbessern!

Zur Veranschaulichung wieder die aktuelle Situation links und das gewünschte Resultat rechts:

Bei diesem Problem geht es darum, die Formen der Chords zu bearbeiten. Start- und Endpunkte können beibelassen werden. Jeder Chord ist geometrisch betrachtet eine Fläche, welche von vier Kurven begrenzt wird: Zwei Kreisbögen wo die Chords an die Ringsegmente anschliessen, und zwei komplexere Kurven im Inneren des Diagrammes.

3.1. Ausgangslage

Studiert man den Quellcode vom Chord-SVG-Pathgenerator (https://github.com/d3/d3/blob/v3.5.17/src/svg/chord.js) findet man für die Chord-Form folgende Funktion:

  function chord(d, i) {
    var s = subgroup(this, source, d, i),
        t = subgroup(this, target, d, i);
    return "M" + s.p0
      + arc(s.r, s.p1, s.a1 - s.a0) + (equals(s, t)
      ? curve(s.r, s.p1, s.r, s.p0)
      : curve(s.r, s.p1, t.r, t.p0)
      + arc(t.r, t.p1, t.a1 - t.a0)
      + curve(t.r, t.p1, s.r, s.p0))
      + "Z";
  }

Wobei “s” für “source”, also den Anfang/Ursprung des Chords, und “t” für “target”, also das Ende/Ziel des Chords steht. Die Formel unterscheidet zwei Fälle, denn es gibt auch Chords, welche als Ursprung und Ziel denselben Bereich haben und deshalb eine andere Form haben. Die Funktionsaufrufe arc(…) zeichnen ein Kreisbogenstück – dies kann so belassen werden. Es ist die Funktion curve(…), die wir verändern möchten:

  function curve(r0, p0, r1, p1) {
    return "Q 0,0 " + p1;
  }

Um diese Funktion zu verstehen, muss man die SVG-Syntax kennen. Mehr Infos dazu gibt es in der W3Schools-Referenz: https://www.w3.org/TR/SVG/paths.html. In diesem Fall wird der Befehl Q verwendet, welcher eine quadratische Bézierkure zeichnet. Eine quadratische Bézierkurve wird definiert durch die Angabe von drei Kontrollpunkten. Im diesem Falle ist der erste Kontrollpunkt implizit gegeben durch den Punkt, an welchem sich der “Stift” befindet, wenn der Befehl aufgerufen wird. Dieser Punkt entspricht dem Anfang der Kurve. Der zweite Kontrollpunkt ist konstant (0, 0), was der Mitte des Diagrammes entspricht. Der dritte Kontrollpunkt entspricht dem Endpunkt der Kurve.

3.2. Kubische Bézierkurven als Lösung

Der Ansatz für bessere Kurvenformen ist nun, statt quadratischen Bézierkurven kubische Bézierkurven zu verwenden. Diese haben statt drei vier Kontrollpunkte, womit man die Form der Kurve besser steuern kann. Wiederum entsprechen der erste und letzte Kontrollpunkt dem Anfang bzw. Ende der Kurve. Die beiden mittleren Kontrollpunkte steuern die Form der Kurve. In diesem speziellen Beispiel gilt: Je näher die beiden mittleren Kontrollpunkte am Zentrum des Diagrammes sind, desto weiter ins Innere stösst auch die Kurve. Dazu folgende Skizze, welche drei kubische Bézierkurven und deren durch die Kontrollpunkte aufgespannten Kontrollpolygone zeigt:

Die Idee ist nun, die Kontrollpunkte nahe ans Zentrum des Diagramms zu setzen, wenn der Chord quer von einer Seite auf die gegenüberliegende Seite geht. Wenn der Chord jedoch nur einen sehr kleinen Winkel aufspannt, also zum Beispiel nur gerade zum benachbarten Bereich geht, werden die Kontrollpunkte weit weg vom Zentrum gesetzt, damit die Kurve denjenigen Chords, die quer über das Diagramm gehen, nicht im Weg steht. Die neuen chord()- und curve(…)-Funktionen sehen wie folgt aus:

function chord(d, i) {
	var s = subgroup(this, source, d, i),
		t = subgroup(this, target, d, i);

	var path = "M" + s.p0
		+ arc(s.r, s.p1, s.a1 - s.a0) + (equals(s, t)
		? curve(s.r, s.p1, s.a1, s.r, s.p0, s.a0)
		: curve(s.r, s.p1, s.a1, t.r, t.p0, t.a0)
		+ arc(t.r, t.p1, t.a1 - t.a0)
		+ curve(t.r, t.p1, t.a1, s.r, s.p0, s.a0))
		+ "Z";

	return path;
}


//....

function curve(r0, p0, a0, r1, p1, a1) {
	var deltaAngle = Math.abs(mod((a1 - a0 + Math.PI), (2 * Math.PI)) - Math.PI);
	var radialControlPointScale = Math.pow((Math.PI - deltaAngle) / Math.PI, 2) * 0.9;
	var controlPoint1 = [p0[0] * radialControlPointScale, p0[1] * radialControlPointScale];
	var controlPoint2 = [p1[0] * radialControlPointScale, p1[1] * radialControlPointScale];
	var cubicBezierSvg = "C " + controlPoint1[0] + " " + controlPoint1[1] + ", " + 
								controlPoint2[0] + " " + controlPoint2[1] + ", " + 
								p1[0] + " " + p1[1];
	return cubicBezierSvg;
}

Damit sieht das Diagramm bereits wieder ein Stück aufgeräumter aus:

HTML-Datei: chord_diagram_custom_curves

4. Farbverläufe

Die letzte visuelle Anpassung ist es nun, die aktuellen Einzelfarben der Chords durch Farbverläufe zu ersetzen. Für diese Problemstellung möchte ich gerne ein weiteres Mal auf Nadieh Bremer von Visual Cinnamon verweisen, und ihren Blog-Beitrag zu genau diesem Thema: https://www.visualcinnamon.com/2016/06/orientation-gradient-d3-chord-diagram. Ihre Lösung lässt sich mit nur wenigen Anpassungen auf dieses Diagramm anwenden:

//Create a gradient definition for each chord
var grads = svg.append("defs").selectAll("linearGradient")
	.data(chord.chords)
	.enter().append("linearGradient")
	//Create a unique gradient id per chord: e.g. "chordGradient-0-4"
	.attr("id", function (d) {
		return "chordGradient-" + d.source.index + "-" + d.target.index;
	})
	//Instead of the object bounding box, use the entire SVG for setting locations
	//in pixel locations instead of percentages (which is more typical)
	.attr("gradientUnits", "userSpaceOnUse")
	//The full mathematical formula to find the x and y locations
	.attr("x1", function (d, i) {
		return innerRadius * Math.cos((d.source.endAngle - d.source.startAngle) / 2 +
			d.source.startAngle - Math.PI / 2);
	})
	.attr("y1", function (d, i) {
		return innerRadius * Math.sin((d.source.endAngle - d.source.startAngle) / 2 +
			d.source.startAngle - Math.PI / 2);
	})
	.attr("x2", function (d, i) {
		return innerRadius * Math.cos((d.target.endAngle - d.target.startAngle) / 2 +
			d.target.startAngle - Math.PI / 2);
	})
	.attr("y2", function (d, i) {
		return innerRadius * Math.sin((d.target.endAngle - d.target.startAngle) / 2 +
			d.target.startAngle - Math.PI / 2);
	});

Und damit präsentiert sich das Diagramm in seiner endgültigen (visuellen) Form:

HTML-Datei: chord_diagram_gradients

5. Interaktivität

5.1. Bereiche umschalten

Ein Klick auf eines der Ringsegmente soll alle Chords, welche nicht mit diesem Ringsegment verbunden sind, ausblenden. Dies ermöglicht eine sehr gute Art, die Informationsmenge des Diagramms auf einen Teilaspekt einzugrenzen.

Als ersten Schritt müssen wir eine Möglichkeit einführen, die Chords herauszufiltern, welche ausgeblendet werden, und welche eben nicht. Dazu werden CSS-Klassen genutzt. Jeder Chord erhält drei CSS-Klassen:

  • chord
  • chord-source-#
  • chord-target-#

Wobei für # die entsprechende ID des Bereiches eingesetzt wird, welcher für diesen Chord als Ziel bzw. Ursprung dient. Der Chord, welcher von Bereich mit ID 4 zum Bereich mit ID 12 geht würde als die Klasse “chord chord-source-4 chord-target-12” erhalten. Wenn jetzt alle Chords eingeblendet werden sollen, welche eine Verbindung mit dem Bereich #12 haben, können einfach alle Chords selektiert werden, die entweder die Klasse “chord-source-12” oder die Klasse “chord-target-12” besitzen.

var chords = container.selectAll("path.chord")
	.data(chord.chords)
	.enter()
	.append("svg:path")
	.attr("class", function (d) {
		return "chord chord-source-" + d.source.index + " chord-target-" + d.target.index;
	})
	.attr("d", customChordPathGenerator().radius(innerRadius))
	//Change the fill to reference the unique gradient ID
	//of the source-target combination
	.style("fill", function (d) {
		return "url(#chordGradient-" + d.source.index + "-" + d.target.index + ")";
	})
	.style("stroke", function (d) {
		return "url(#chordGradient-" + d.source.index + "-" + d.target.index + ")";
	})
	.style("fill-opacity", "0.7");

Für das Ein- und Ausblenden werden drei einfache Funktionen erstellt. Eine neue globale Variable “focusedChordGroupIndex” speichert, welcher Bereich aktuell eingeblendet ist (oder null, wenn keiner).

//Hides all chords except the chords connecting to the subgroup / 
//location of the given index.
function highlightChords(index) {
	//If this subgroup is already highlighted, toggle all chords back on.
	if (focusedChordGroupIndex === index) {
		showAllChords();
		return;
	}

	hideAllChords();

	//Show only the ones with source or target == index
	d3.selectAll(".chord-source-" + index + ", .chord-target-" + index)
		.transition().duration(500)
		.style("fill-opacity", "0.7")
		.style("stroke-opacity", "1");

	focusedChordGroupIndex = index;
};

function showAllChords() {
	svg.selectAll("path.chord")
		.transition().duration(500)
		.style("fill-opacity", "0.7")
		.style("stroke-opacity", "1");

	focusedChordGroupIndex = null;
};

function hideAllChords() {
	svg.selectAll("path.chord")
		.transition().duration(500)
		.style("fill-opacity", "0")
		.style("stroke-opacity", "0");
};

Was noch fehlt, ist der Event-Handler auf den Ringsegmenten:

g.append("svg:path")
	.attr("d", arc)
	.style("fill", function (d) { 
		return locations[d.index].color; 
	})
	.style("stroke", function (d) { 
		return d3.rgb(locations[d.index].color).brighter(); 
	})
	.on("click", function (d) { highlightChords(d.index) });

5.2. Chords hervorheben

Wenn der Cursor auf einen Chord bewegt wird, sollen alle anderen Chords nur noch sehr schwach angezeigt werden, damit der Chord unter der Maus hervorgehoben zu sein scheint. Hier muss jedoch noch berücksichtigt werden, dass einige Chords aktuell gerade komplett ausgeblendet sind. Diese sollen von diesem Hover-Effekt unberührt bleiben.

var chords = container.selectAll("path.chord")
	.data(chord.chords)
	.enter()
	.append("svg:path")
	.attr("class", function (d) {
		return "chord chord-source-" + d.source.index + " chord-target-" + d.target.index;
	})
	.attr("d", customChordPathGenerator().radius(innerRadius))
	//Change the fill to reference the unique gradient ID
	//of the source-target combination
	.style("fill", function (d) {
		return "url(#chordGradient-" + d.source.index + "-" + d.target.index + ")";
	})
	.style("stroke", function (d) {
		return "url(#chordGradient-" + d.source.index + "-" + d.target.index + ")";
	})
	.style("fill-opacity", "0.7")
	.on("mouseover", function(d) { 
		if (focusedChordGroupIndex === null || 
			d.source.index === focusedChordGroupIndex || 
			d.target.index === focusedChordGroupIndex) {
			if (focusedChordGroupIndex === null) {
				d3.selectAll(".chord")
				  .style("fill-opacity", 0.2)
				  .style("stroke-opacity", 0.2);
				d3.select(this).style("fill-opacity", 1);
			}
			else {
				d3.selectAll(".chord.chord-source-" + focusedChordGroupIndex + ", " + 
							 ".chord.chord-target-" + focusedChordGroupIndex)
				  .style("fill-opacity", 0.2)
				  .style("stroke-opacity", 0.2);
				d3.select(this).style("fill-opacity", 1);
			}
		}
	})
	.on("mouseout", function(d) { 
		if (focusedChordGroupIndex === null) {
			d3.selectAll(".chord")
			  .style("fill-opacity", 0.7)
			  .style("stroke-opacity", 1);
		}
		else {
			d3.selectAll(".chord.chord-source-" + focusedChordGroupIndex + ", " +
						 ".chord.chord-target-" + focusedChordGroupIndex)
			  .style("fill-opacity", 0.7)
			  .style("stroke-opacity", 1);
		}
	});

5.3. Tooltips

Wenn man mit dem Mauscursor über die Elemente des Diagramms (Ringsegmente und Chords) fährt, soll ein Tooltip erscheinen, welches die Information, die dieses Element darstellt, in Worten ausdrückt. Für das Tooltip verwenden wir einen ganz normalen HTML-Div. Für alle Tooltips wird derselbe Div wiederverwendet. Diesen Div erstellen wir ganz am Anfang, wenn das Diagramm gezeichnet wird, halten es jedoch unsichtbar, bis es gebraucht wird. Das Styling machen wir auch gleich mit Javascript, CSS wäre aber ebenfalls möglich.

var toolTip = root.append("div")
  .classed("tooltip", true)
  .style("opacity", 0)
  .style("position", "absolute")
  .style("text-align", "center")
  .style("padding", "6px")
  .style("font", "12px sans-serif")
  .style("color", "black")
  .style("background", "silver")
  .style("border", "1px solid gray")
  .style("border-radius", "8px")
  .style("pointer-events", "none");

Für das Ein- und Ausblenden gibt es ein paar kleine Hilfsfunktionen zu programmieren: Einmal eine Funktion, um das Tooltip für einen Chord einzublenden (die Informationen zum entsprechenden Chord werden mitgegeben), zum Zweiten dasselbe für Ringsegmente/Arcs, und schliesslich noch eine Funktion zum Ausblenden des Tooltips. In diesen Funktionen wird dann auch gerade der Text für das Tooltip zusammengesetzt. Für die Positionierung wird d3.event.pageX und d3.event.pageY verwendet, was den x/y-Koordinaten des Ortes entspricht, an welchem der Maus-Event stattfand.

function showChordToolTip(chord) {
	var prompt = "";
	  
	if (chord.source.index !== chord.target.index) {
		prompt += chord.source.value + " Kunden gingen von " + 
			locations[chord.target.index].name + " nach " + 
			locations[chord.source.index].name + ".";
		prompt += "<br>";
		prompt += chord.target.value + " Kunden gingen von " + 
			locations[chord.source.index].name + " nach " + 
			locations[chord.target.index].name + ".";
	}
	else {
		prompt += chord.source.value + " Kunden blieben in " + 
			locations[chord.source.index].name + ".";
	}
	
	toolTip
	  .style("opacity", 1)
	  .html(prompt)
	  .style("left", d3.event.pageX - toolTip.node().getBoundingClientRect().width / 2 + "px")
	  .style("top", (d3.event.pageY - 50) + "px");
};

function showArcToolTip(arc) {
	var prompt = Math.round(arc.value) + " Kunden befinden sich in " + locations[arc.index].name + ".";

	toolTip
	  .style("opacity", 1)
	  .html(prompt)
	  .style("left", d3.event.pageX - toolTip.node().getBoundingClientRect().width / 2 + "px")
	  .style("top", (d3.event.pageY - 30) + "px");
};

function hideToolTip() {
	toolTip.style("opacity", 0);
};

Schliesslich müssen die Funktionen nur noch aufgerufen werden, wenn man mit der Maus über ein Ringsegment oder Chord fährt.

Für die Ringsegmente:

g.append("svg:path")
	.attr("d", arc)
	.style("fill", function (d) { 
		return locations[d.index].color; 
	})
	.style("stroke", function (d) { 
		return d3.rgb(locations[d.index].color).brighter(); 
	})
        .on("click", function (d) { highlightChords(d.index) })
	.on("mouseover", function(d) { 
		showArcToolTip(d);
	})
	.on("mouseout", function(d) { hideToolTip() });

Für die Chords können die Funktionsaufrufe in die Event-Handler aus dem vorherigen Schritt eingefügt werden.

 

Und damit ist das Diagramm endlich endgültig fertig! Hier folgt noch die HTML-Datei des kompletten Diagrammes: chord_diagram_complete. Hier klicken, um den ganzen Quellcode herunterzuladen (Rechtsklick -> Speichern unter), und das Diagramm und dessen Interaktivität selber auszuprobieren!

About the Author
Severin, geboren 1994, absolvierte seine Lehre als Informatiker Applikationsentwicklung bei der Noser Young und Noser Engineering und arbeitet aktuell bei Noser Engineering als Junior Software Developer und studiert an der ZHAW Informatik im Bachelorstudiengang.

Leave a Reply

*

captcha *