Tagcloud mit PHP und JavaScript erstellen - Word Cloud d3

Tagcloud mit PHP und JavaScript erstellen - Word Cloud d3
Von Lars Ebert am 23.04.12, 10:00
Kategorien: CSS, JavaScript, PHP, Programmieren and Tutorials

Seit einigen Monaten quälte mich ein Problem. Ich hatte angefangen, Wordles in Javscript zu implementieren, um diese dann auf einer Seite dynamisch einzubinden. Doch leider scheiterte ich an der Performance. Doch endlich habe ich eine Lösung gefunden, denn Jason Davies hat es geschafft, Word Clouds mit JavaScript zu rendern.

Das ist wunderbar, denn diese sind mit ein paar JavaScript-Kenntnissen sehr flexibel in eine Seite einbindbar. Doch gibt es dabei einiges zu beachten. Damit der Einstieg auch gut gelingt, schreibe ich hier ein kleines Tutorial, wie man einfach mit Word Cloud d3 eine Tagcloud erstellt.

Schritt 1: Word Cloud d3 herunterladen

Als erstes müssen wir natürlich die Grundlage der Word Cloud herunterladen, das Script von Jason Davies. Auf GitHub klicken wir dazu auf »Download as zip«. In diesem Zip-Archiv finden wir alles, was wir für tolle Word Clouds brauchen.

Wir legen nun eine PHP-Datei an, die erst einmal nur folgenden Inhalt hat:


<?php echo '<?xml version="1.0" encoding="UTF-8" ?>'; ?>
<!DOCTYPE html>

<html xmlns="http://www.w3.org/1999/xhtml">

<head>
	<title>Wordcloud</title>
	<meta http-equiv="content-type" content="text/html; charset=UTF-8" />
	
	<script type="text/javascript" src="js/d3.js"></script>
	<script type="text/javascript" src="js/d3.layout.cloud.js"></script>
	<script type="text/javascript" src="js/main.js"></script>
	
	<link rel="stylesheet" type="text/css" href="css/main.css" />
</head>

<body onload="javascript: loaded();">
	
</body>

</html>

Eventuell musst du die Pfade zu den Javascript- und CSS-Dateien anpassen, ansonsten kannst du das aber so übernehmen!

Tags mit PHP generieren

Nun generieren wir mit PHP ein paar Tags. Woher die Tags kommen, kann ganz unterschiedlich sein, deshalb stelle ich hier ein paar Beispiele vor. Doch zunächst schauen wir uns einmal an, in welchem Format wir die Tags brauchen. Grundsätzlich können wir die Tags als beliebiges Array übergeben und dann im JavaScript individuelle Funktionen schreiben, die die jeweils benötigten Elemente zurückgeben. So kann die Liste sehr flexibel sein. Zum Beispiel könnten wir einfach nur ein Array von Worten übergeben und per JavaScript später eine zufällige Größe definieren.


var words = ["Dies", "Sind", "Verschiedene", "Worte"];

Wir können aber auch direkt in das Array Schriftfarbe und/oder Schrifttyp übergeben, sofern wir dies benötigen.


var words = [{
	"text":		"Tag",
	"size":		2,
	"color":	"#fb145e",
	"font":		"Impact"
}, {
	"text":		"Another",
	"size":		1,
	"color":	"#bdecad",
	"font":		"Arial"
}, {
	"text":		"Test",
	"size":		1,
	"color":	"#1e2f55",
	"font":		"Arial"
}, {
	"text":		"Words",
	"size":		3,
	"color":	"#3344f5",
	"font":		"Impact"
}];

Fürs Erste beschränken wir uns aber darauf, einfach nur Paare aus Worten und absoluten Häufigkeiten zu generieren.


var words = [{
	"text":	"sexy",
	"size":	1
}, {
	"text":	"tagclouds",
	"size":	7
}, {
	"text":	"dynamisch",
	"size": 1
}, {
	"text":	"seite",
	"size":	5
}];

Natürlich wollen wir diese jedoch dynamisch generieren, deshalb benutzen wir PHP.

Beispiel 1: Häufigkeit von Worten aus einer Textdatei

Um die Häufigkeit von Worten in einem Text aus einer Textdatei (oder einer anderen Quelle) zu bestimmen, verwenden wir einfach reguläre Ausdrücke.


<script type="text/javascript">
	/* <![CDATA[ */
		var words = <?php
			
			require_once('data/commonwords.php');
			
			$content = file_get_contents('data/Wordclouds.txt');
			
			$wordsList = array();
			$words = array();
			preg_match_all('/[a-zA-ZöäüÖÄÜß]{3,}/', strtolower($content), $wordsList);
			
			foreach($wordsList[0] as $word) {
				if(!isCommon($word)) {
					if(isset($words[$word])) {
						$words[$word]++;
					}
					else {
						$words[$word] = 1;
					}
				}
			}
			
			$newWords = array();
			foreach($words as $word => $count) {
				$newWords[] = array('text' => $word, 'size' => $count);
			}
			
			echo json_encode($newWords);
			
		?>
	/* ]> */
</script>

Diesen Code können wir einfach vor die Verlinkung von d3.js einfügen, um das Wort-Array in die Variable words zu speichern. Doch was macht dieser PHP-Code?

Zunächst wird in Zeile 5 die Datei data/commonwords.php eingebunden. In dieser habe ich eine einfache Funktion isCommon() geschrieben, die true zurück, wenn das übergebene Wort ein irrelevantes Wort wie »der«, »die«, »das«, »und« und so weiter ist. So kann ich diese Wörter relativ leicht herausfiltern.

Als nächstes laden wir den Inhalt der Datei Wordclouds.txt und legen die Arrays $wordsList und $words an, diese brauchen wir nämlich gleich. Dann laden wir mithilfe von regulären Ausdrücken jedes Wort mit drei und mehr Zeichen aus dem Inhalt der Datei.

Mit der Foreach-Schleife gehen wir nun all diese Worte durch und zählen in dem Array $words die absolute Häufigkeit der Worte.

Zuletzt generieren wir aus diesem Array noch die benötigten Wort- und Häufigkeits-Paare und geben diese mithilfe von json_encode() im benötigten Format aus.

Beispiel 2: Häufigkeit von Tags in Wordpress auslesen.

Alternativ könnte man auch die Häufigkeit von Tags in einem Wordpress-Blog bestimmen.


require_once('../includes/mysql.inc.php');

$mysqlResult = mysql_query("SELECT wp_terms.name AS text, COUNT(wp_term_relationships.object_id) AS size FROM wp_term_taxonomy, wp_terms, wp_term_relationships WHERE wp_term_taxonomy.taxonomy = 'post_tag' AND wp_terms.term_id = wp_term_taxonomy.term_id AND wp_term_taxonomy.term_taxonomy_id = wp_term_relationships.term_taxonomy_id GROUP BY wp_terms.name ORDER BY size DESC");

$words = array();

while(($word = mysql_fetch_object($mysqlResult)) !== false) {
	$words[] = $word;
}

echo json_encode($words);

Der Kern dieses Scripts ist die MySQL-Abfrage, hier wird schon der Zusammenhang zwischen allen Tabellen hergestellt, das Ergebnis sind schon Tag-Häufigkeits-Paare. Diese müssen wir anschließend nur noch in ein Array schieben und wieder zu JavaScript ausgeben.

Beispiel 3: Schlagworte aus Twitter-Meldungen extrahieren

Im letzten Beispiel lesen wir die Schlagworte aus einem Twitter-Kanal oder einer Twitter-Suche aus.


require_once('data/commonwords.php');

$data = json_decode(file_get_contents('http://search.twitter.com/search.json?q=' . urlencode('from:lars_ebert')));

$words = array();
foreach($data->results as $tweet) {
	$wordList = array();
	preg_match_all('/[#@]?[a-zA-ZöäüÖÄÜß]{3,}/', strtolower($tweet->text), $wordsList);
	
	foreach($wordsList[0] as $word) {
		if(!isCommon($word)) {
			if(isset($words[$word])) {
				$words[$word]++;
			}
			else {
				$words[$word] = 1;
			}
		}
	}
}

$newWords = array();
foreach($words as $word => $count) {
	$newWords[] = array('text' => $word, 'size' => $count);
}

echo json_encode($newWords);

Hier brauchen wir einfach nur mit der Twitter Search-API eine beliebige Suche ausführen (in diesem Fall meine letzten Tweets) und bekommen ein JSON-Objekt zurück. Hieraus lesen wir dann in einer Schleife aus allen Tweets die einzelnen Worte aus. Allerdings erlauben wir diesmal im regulären Ausdruck, im Gegensatz zum Beispiel 1, auch ein @ oder # am Beginn des Wortes. Der Rest ist äquivalent zum ersten Beispiel.

Diese drei Beispiele sollen zeigen, dass die Möglichkeiten quasi unbegrenzt sind. Mithilfe von PHP kann man die Worte aus beliebigen Quellen auslesen und aufbereiten. Doch genug der Worte — auf der nächsten Seite erfahren wir endlich, wie man aus den Worten jetzt die Tagcloud erstellt.

Aus dem Array wird eine Wordcloud

Grundlagen: das Nötigste schnell erklärt

Um es zu Beginn nicht gleich so kompliziert zu machen, werde ich nun erst einmal Schritt für Schritt erklären, wie man eine Wordcloud erstellt.

Als erstes legen wir zwei Variablen an.


var wordcloud, size = [700, 300];

In der Variable wordcloud speichern wir das Wordcloud-Element, damit wir wärend der Erstellung einfach darauf zugreifen können. In der Variable size speichern wir die Breite und Höhe der Wordcloud. Ich habe hier 700x300 Pixel gewählt, damit ich die Wordcloud am Ende als Titelbild für diesen Artikel verwenden kann. Du kannst aber einen beliebigen Text einfügen.

Als nächstes definieren wir die Funktion loaded() welche, so im HTML-Quellcode definiert, aufgerufen wird, sobald die Seite vollständig geladen wurde. So stellen wir sicher, dass das Element, in welchem wir die Tagcloud anlegen, auch schon existiert.


function loaded() {
	d3.layout.cloud()
		.size(size)
		.words(words)
		.fontSize(function(d) { return d.size; })
		.on("end", draw)
		.start();
}

Mit d3.layout.cloud() können wir auf die Methoden, die Jason Davies erstellt hat, zugreifen. Diese sind allerdings nur für das Kalkulieren der Positionen für die Worte zuständig. Die tatsächliche Anzeige der Tagcloud passiert hier noch nicht. So könnten wir zum Beispiel die Tagcloud im Hintergrund generieren, sie aber erst später irgendwo auf der Seite anzeigen.

Wir übergeben der Klasse cloud() nun einige Details, wie die Tagcloud generiert werden soll. In Zeile 3 übergeben wir zum Beispiel Breite und Höhe, die wir vorher schon definiert hatten, in Zeile 4 werden die Worte aus dem Array words übergeben. Die Schriftgröße können wir in Zeile 5 definieren, indem wir eine Callback-Funktion übergeben. Dieser wird das Array-Element im Parameter d übergeben. Also finden wir die Größe, die wir vorher per PHP übergeben haben, in d.size.

Anschließend definieren wir noch in Zeile 6, dass die Funktion draw() aufgerufen werden soll, sobald die Generierung der Cloud fertiggestellt ist. In dieser Funktion müssen wir uns gleich darum kümmern, das die Tagcloud auch angezeigt wird. Aber erstmal starten wir die Berechnung der Cloud mit start.

Nun kommen wir zu der Funktion draw().


function draw(words) {
	wordcloud = d3.select("body")
		.append("svg")
			.attr("width", size[0])
			.attr("height", size[1])
		.append("g")
			.attr("transform", "translate(" + (size[0]/2) + "," + (size[1]/2) + ")");
	
	wordcloud.selectAll("text")
			.data(words)
		.enter().append("text")
			.style("font-size", function(d) { return d.size + "px"; })
			.attr("text-anchor", "middle")
			.attr("transform", function(d) {
			return "translate(" + [d.x, d.y] + ")rotate(" + d.rotate + ")";
			})
			.text(function(d) { return d.text; });
}

Der Funktion wird das Array mit den fertig berechneten Worten übergeben. Hier hat jedes Element die Eigenschaften text, size, rotate, x und y, die definieren, wo und wie die Tagcloud definiert wird.

Mit d3.select("body") wählen wir das HTML-Body-Element aus, in Zeile 3 fügen wir dann eine SVG an. In den Zeilen 4 und 5 definieren wir die Breite und Höhe der Grafik. Wichtig: Hier müssen zwingend die selben Angaben gemacht werden wie bei der Generierung der Wordcloud, sonst wird sie abgeschnitten!

Nun fügen wir ein g-Element zu der SVG hinzu, das ist im Grunde nur ein Container, ähnlich wie ein Div bei HTML. Diesen Container bewegen wir in Zeile 7 genau in die Mitte der Grafik. Denn die Positionen der Worte liegen relativ zur Mitte, der Ursprung liegt also hier genau in der Mitte der Grafik. Den Rückgabewert, also den g-Container, speichern wir in der Variable wordcloud, damit wir weiter darauf zugreifen können.

Nun müssen wir die einzelnen Worte in die Grafik einfügen. Das macht der Code in Zeilen 9-17. Als erstes wählen wir in der Wordcloud alle Text-Elemente aus (momentan gibt es aber noch keine Text-Elemente), mit der Anweisung data(words).enter() können wir für jedes Wort eine Anweisung ausführen. Hier fügen wir für jedes Wort mit append("text") ein Textelement ein, diesem übergeben wir anschließend noch einige Eigenschaften in den Zeilen 12-17.

In Zeile 12 wird die Schriftart definiert, wieder mit einem Callback. Im Parameter finden wir das Objekt für das aktuelle Wort, welches die oben genannten 5 Eigenschaften hat. Hier übergeben wir natürlich die Eigenschaft size. In Zeile 13 definieren wir, dass die Text-Position sich an der Mitte des Textes orientiert, so werden die Worte wie von der Word-Cloud berechnet, korrekt positioniert. In Zeile 14-16 übergeben wir wieder eine Funktion, die dafür zuständig ist, den Text zu positionieren. Mit translate verschieben wir das Element um x nach rechts und um y nach unten und mit Rotate rotieren wir das Element. Den Winkel bestimmt cloud() zufällig, da wir nichts anderes angegeben haben. Schließlich übergeben wir noch in Zeile 17 den Text aus der Eigenschaft text als gewünschten Text des Elements.

Das Generieren und Anzeigen der Tagcloud funktioniert nun. Schauen wir uns die Wolke mal im an.

Die Wordcloud ist viel zu klein.
Ein kleines Problem: Die Wordcloud ist viel zu klein!

Leider ist die Wordcloud viel zu klein. Wie kommt das? Vorhin haben wir als Schriftgröße die absloute Häufigkeit der Worte im Text gewählt. Allerdings kommen die meisten Worte nur ein bis fünf mal im Text vor, haben dann also auch nur eine Schriftgröße von einem bis fünf Pixel.

Schriftgrößen anpassen

Dieses Problem können wir einfach lösen, indem wir die Schriftgröße in einen bestimmten Bereich verschieben. Dazu dienen uns die scale-Funktionen von D3.


var fontSize = d3.scale.log().range([15, 100]);

Wir greifen in der Variable fontSize, die wir direkt nach wordcloud und size definieren, auf die D3 scale-Funktion zu, in diesem Fall auf eine logarhitmische Skalierung. Was macht diese Funktion nun? Mit dieser Funktion werden die Werte nun so angepasst, dass der größte Wert 100 und der kleinste Wert 15 wird. Unsere kleinste Schriftgröße wird also später 15 Pixel ergeben. Warum brauchen wir hier denn logarhitmische Skalierung? Ganz einfach, wir haben meistens eine unregelmäßige Verteilung von Häufigkeiten. Es gibt sehr viele Worte, die nur ein oder zwei mal vorkommen, jedoch sehr wenige Worte, die häufiger als 4 oder 5 mal auftauchen. Damit die Schriftgößen hinterher etwas harmonischer wirken und wir nicht 99% aller Worte ganz klein und 1% der Worte riesig sind, transformieren wir die Werte logarhitmisch, was dieser ungleichmäßigen Verteilung etwas entgegenwirkt.

Nun müssen wir die Größen-Skalierung noch auf die Schriftgröße anwenden. Dazu ändern wir unsere loaded()-Funktion etwas ab.


function loaded() {
	d3.layout.cloud()
		.size(size)
		.words(words)
		.fontSize(function(d) { return fontSize(+d.size); })
		.on("end", draw)
		.start();
}

Hier wenden wir nun die in der Variable fontSize Funktion angewendet, die Größe d.size wird angepasst, sodass sie nun irgendwo zwischen 15 und 100 liegt. Der angepasste Wert wird übrigens automatisch an die Funktion draw() übergeben, sodass wir hier nichts mehr ändern müssen.

Die Tagcloud funktioniert jetzt endlich. Nun können wir aber an der Tacloud noch einiges verbessern. Ein paar dieser Verbesserungen will ich auf der nächsten Seite vorstellen.

Anpassen der Schriftart

Mir gefällt die Tagcloud mit der Standard-Schriftart Arial nicht besonders gut. Deshalb benutzen wir jetzt die Schriftart Impact. Als erstes müssen wir dem Generator mitteilen, dass wir diese Schriftart benutzen, denn er muss ja dafür sorgen, dass die Worte auch alle gut platziert werden.


function loaded() {
	d3.layout.cloud()
		.size(size)
		.words(words)
		.font("Impact")
		.fontSize(function(d) { return fontSize(+d.size); })
		.on("end", draw)
		.start();
}

Schauen wir uns die Tagcloud wieder an, stellen wir mit Schrecken fest, dass sich die Worte an vielen Stellen überlappen.

An dieser Stelle wäre ich wohl ohne die nette Hilfe von Jason Davies verzweifelt, denn ich suchte ewig nach dem Fehler, bis ich ihm schließlich eine Mail schrieb und er mir innerhalb einer Stunde freundlich antwortete und mir half. Damit jetzt nicht alle an Jason Davies schreiben müssen, schreibe ich die Lösung mal einfach hier hin — obwohl er sich sicherlich über das Feedback freuen würde.

Die Lösung des Problems ist im Grunde sehr einfach, man muss nur erst einmal drauf kommen. Das Script kümmert sich nämlich nicht von selbst um die Schriftart im CSS.


body {
	font-family: "Impact";
}

Nachdem wir dieses CSS eingefügt haben, funktioniert unsere Tagcloud ohne Probleme wieder.

Die Tagcloud wird nun mit Impact angezeigt
Mit Impact hat die Tagcloud viel mehr Wirkung.

Schwarz-Weiß ist langweilig — wir bingen Farbe ins Spiel

Als nächstes kümmern wir uns darum, die Farbe der Tagcloud ein wenig zu variieren. Dazu können wir wieder die oben schon verwendeten d3.scale verwänden. Diesmal benutzen sie jedoch nicht, um einen Wert zu skalieren, sondern aus verschiedenen Farben eine für jeden Tag auszuwählen.


var fillColor = d3.scale.ordinal().range(['#ff0000', '#0000ff', '#000000']);

Dazu legen wir direkt am Anfang des JavaScript die Funktion fillColor an, in welcher wir beliebig viele Farben definieren können. Ich habe beispielsweise die drei Farben rot, blau und schwarz eingetragen.

Nun können wir in der Funktion draw() die Farben mit der neuen Funktion anpassen. Die Berechnung der Worte können wir diesmal unangetastet lassen, denn die Farbe beeinflusst ja nicht die Positionierung der Worte.


function draw(words) {
	wordcloud = d3.select("body")
		.append("svg")
			.attr("width", size[0])
			.attr("height", size[1])
		.append("g")
			.attr("transform", "translate(" + (size[0]/2) + "," + (size[1]/2) + ")");
	
	wordcloud.selectAll("text")
			.data(words)
		.enter().append("text")
			.style("font-size", function(d) { return d.size + "px"; })
			.style("fill", function(d) { return fillColor(d.text.toLowerCase()); })
			.attr("text-anchor", "middle")
			.attr("transform", function(d) {
			return "translate(" + [d.x, d.y] + ")rotate(" + d.rotate + ")";
			})
			.text(function(d) { return d.text; });
}

Mit der Funktion fillColor() können wir jetzt also aus dem Tag eine Farbe bekommen. Wir benutzen diese Farbe dann als Schriftfarbe. Schauen wir uns das Ergebnis an.

Jetzt ist die Tagcloud bunt
Die Tagcloud ist so schön bunt.

So können wir beliebige Farbpaletten definieren. Wir können aber auch auf eine Farbpalette zugreifen, die d3 uns schon zur Verfügung stellt.


var fillColor = d3.scale.category20c();

Definieren wir die Farbpalette zum Beispiel so, bekommen wir folgende Tagcloud.

Die Tagcloud besteht aus 20 Farben
Nun haben wir für unsere Wordcloud eine neue Farbpalette definiert.

Natürlich können wir auch die Hintergrundfarbe anpassen.


function draw(words) {
	wordcloud = d3.select("body")
		.append("svg")
			.attr("width", size[0])
			.attr("height", size[1])
			.style("background-color", "#000000")
		.append("g")
			.attr("transform", "translate(" + (size[0]/2) + "," + (size[1]/2) + ")");
	
	wordcloud.selectAll("text")
			.data(words)
		.enter().append("text")
			.style("font-size", function(d) { return d.size + "px"; })
			.style("fill", function(d) { return fillColor(d.text.toLowerCase()); })
			.attr("text-anchor", "middle")
			.attr("transform", function(d) {
			return "translate(" + [d.x, d.y] + ")rotate(" + d.rotate + ")";
			})
			.text(function(d) { return d.text; });
}
Die Tagcloud hat jetzt einen schwarzen Hintergrund
Unsere Tagcloud hat jetzt einen schwarzen Hintergrund.

Rotation der Worte verändern

Noch weiter können wir das aussehen der Tagcloud verändern, indem wir festlegen, wie die Worte rotiert werden dürfen.


function loaded() {
	d3.layout.cloud()
		.size(size)
		.words(words)
		.font("Impact")
		.fontSize(function(d) { return fontSize(+d.size); })
		.rotate(function() { return ~~(Math.random() * 5) * 30 - 60; })
		.on("end", draw)
		.start();
}

So können wir zum Beispiel definieren, dass die Worte sich auf fünf verschiedene Winkel verteilen, nämlich um -60°, -30°, 0°, 30° oder 60° rotiert. Das aus mathematischer Sicht besondere an dieser Verteilung ist, dass jeweils die beiden Orientierungen -60 und 30 beziehungsweise -30 und 60 genau rechtwinklig aufeinander stehen. So kommt es trotz der Unordnung vor, dass Worte genau im rechten Winkel stehen können.

Die Worte sind mit fünf verschiedenen Winkeln ausgerichtet
Ordnung und Chaos

Eine andere Möglichkeit ist es, nur horizontalen und vertikalen Text zu benutzen.


.rotate(function() { return ~~(Math.random() * 2) * 90; })
Nun gibt es nur horizontalen und vertikalen Text.
Die Orientierung der Worte ist auf horizontalen und vertikalen Text limitiert, wodurch die Wolke sehr ordentlich wirkt.

Die letzte Möglichkeit ist, die Worte einfach absolut zufällig zu drehen.


.rotate(function() { return ~~(Math.random() * 180) - 90; })
Die Worte sind zufällig gedreht
Chaos in der Wordcloud.

Mit Word Cloud D3 erstellen wir endlich tolle Tagclouds!

Wie man sieht, lassen sich so sehr flexibel Wordclouds anlegen, ohne viel Hintergrundwissen über den zugrunde liegenden Algorithmus. Ich freue mich jedenfalls, denn in Maßen eingesetzt können Tagclouds dem Nutzer wirklich einen Mehrwert bieten!

Übrigens kannst du eine funktionierende Demo mit allen nötigen Dateien herunterladen, um direkt loszulegen.

Was sagst du?

Welches Potential traust du Wordclouds zu? Wo machen sie Sinn, wo nicht? Deine Meinung interessiert mich! Als hinterlasse mir doch einen Kommentar ;)