Das Linien-Diagramm – Interaktive SVG-Diagramme im Web

Das Linien-Diagramm – Interaktive SVG-Diagramme im Web
Von Lars Ebert am 21.09.15, 11:00
Kategorien: JavaScript, Programmieren and Tutorials

Diagramme sind ein wichtiges Werkzeug, um Daten zu visualisieren. Sie können komplexe Zusammenhänge sichtbar machen. Auch im Web bietet sich der Einsatz von Diagrammen an – zum Beispiel zur Visualisierung von Nutzer- oder Verkaufszahlen, in wissenschaftlichen Artikeln etc.

Es gibt bereits einige Tools und Bibliotheken, die uns bei der Nutzung von Diagrammen unterstützen können. Wer neugierig ist, gerne selbst experimentiert oder einfach nicht die passende Bibliothek findet, kann trotzdem mit JavaScript und SVG sehr einfach eigene Diagramme erstellen. In diesem Artikel erfährst Du, wie Du Linien-Diagramme aus Deinen Daten generieren kannst.

Artikelserie: Interaktive SVG-Diagramme im Web

Dieser Artikel ist Teil einer mehrteiligen Artikelserie. Lies dir auch die restlichen Teile durch!

  1. Das Linien-Diagramm – Interaktive SVG-Diagramme im Web
  2. Events im Diagramm – Interaktive SVG-Diagramme im Web

Vorab das Resultat

So wird das Diagramm am Ende dieses Tutorials aussehen. Das Prinzip ist gar nicht so kompliziert, wie es auf den ersten Blick aussieht. Im folgenden kannst Du die Entstehung des Diagramms Schritt für Schritt nachvollziehen.

Schritt 1: HTML

Bevor wir die erste Zeile JavaScript schreiben, sollten wir erst das HTML-Markup schreiben. Mein Markup sieht so aus:

<!DOCTYPE html>
<html lang="de">
<head>
	<meta charset="UTF-8">
	<title>Lininen-Diagramm</title>
</head>
<body>
	<div id="chart">
	
	</div>
	
	<script type="text/javascript" src="js/chart.js"></script>
	<script type="text/javascript" src="js/line-chart.js"></script>
	<script type="text/javascript" src="js/main.js"></script>
</body>
</html>

In dem div#chart wird später das Diagramm eingefügt. Ich spalte mein JavaScript gerne in übersichtliche Teile auf, deshalb habe ich drei JavaScript-Dateien eingebunden: chart.js, line-charts und main.js. Da ich plane, außer Linien-Diagrammen noch weitere Diagramm-Typen zu entwickeln, teile ich den Diagramm-Code auf: In chart.js kommt für alle Typen gültiger Code, in line-charts.js der spezialisierte Code für Linien-Diagramme. Diese beiden Dateien sind vom restlichen Code für die Webseite getrennt, sodass ich sie später wieder nutzen kann. In main.js geschieht genau diese Nutzung.

Schritt 2: Prototyping

In JavaScript Objekt-orientiert zu arbeiten, ist gar nicht so schwer, wie es sich anhört. Im Grunde tut es jeder, denn JavaScript besteht quasi nur aus Objekten.

Objekt-Prototypen kann man in JavaScript sehr einfach ähnlich wie Klassen in anderen Sprachen nutzen. Für dieses Tutorial werden wir zwei Objekt-Prototypen benötigen: Chart und LineChart, wobei der Prototyp für die Linien-Diagramme einige Funktionen vom allgemeineren Diagramm-Prototypen erben wird.

function Chart() {}

Chart.prototype = {};

Dies ist unser Chart-Prototype. Die Funktion Chart() wird als Konstruktor genutzt, im Objekt Chart.prototype können später Funktionen und Variablen definiert werden, die wir in allen Diagrammen benötigen könnten.

Bisher ist der Prototyp noch leer, im Laufe des Artikels werden hier Funktionen hinzukommen.

function LineChart(container, data) {
	Chart.call(this);
}

LineChart.prototype = Object.create(Chart.prototype, {
	constructor: {
		value: LineChart
	}
});

Auch der Prototype LineChart bleibt vorerst leer. Hier führen wir lediglich die nötigen Schritte durch, um von Chart zu erben. In Zeile 2 rufen wir innerhalb des Konstruktors den Konstruktor von Chart auf. Mit .call(this) sorgen wir dafür, dass das Schlüsselwort this im Eltern-Konstruktor auf das gleiche Objekt referenziert wie hier. Mit Object.create() erstellen wir den Objekt-Prototypen für LineChart, wobei dieser Prototype wiederum den Prototypen von Chart als Prototypen verwendet. So entsteht eine Prototype-Kette, in der Objekte von Chart zu LineChart »durchgereicht« werden.

Da der Chart-Prototype die Funktion Chart als Konstruktor verwendet und der LineChart-Prototype auf Basis dieses Prototyps erstellt wurde, benutzt der LineChart-Prototype bisher auch diesen Konstruktor. Deshalb definieren wir abschließend noch in Zeile 6-8 die Funktion LineChart als korrekten Konstruktor für diesen Prototypen.

Schritt 3: Daten

Wer ein Diagramm benötigt, hat meist schon irgendwelche Daten. Für diese Demo habe ich aber keine. Deshalb habe ich schnell ein paar Zeilen JavaScript geschrieben, die mir zufällige Daten generieren.

var weekdays = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
var months = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
var from = new Date(2015, 8, 21-28, 0, 0, 0);
var to = new Date(2015, 8, 21, 0, 0, 0);

var data = {
	labels: [],
	lines: [{
		title: 'Messwerte 1',
		data: []
	}, {
		title: 'Messwerte 2',
		data: []
	}]
};

while(from <= to) {
	data.labels.push(weekdays[from.getDay()] + ', ' + from.getDate() + '. ' + months[from.getMonth()] + ' ' + from.getFullYear());
	
	for(var lineIndex = 0; lineIndex < data.lines.length; lineIndex++) {
		data.lines[lineIndex].data.push(Math.round(lineIndex * 1000 + 500 + (Math.random() + Math.random()) * 500));
	}
	
	from.setDate(from.getDate() + 1);
}

Wichtig ist hier für dieses Tutorial nur das Format der Daten. Alle Daten liegen in dem Objekt data. Dieses Objekte besteht aus labels und lines. In labels wird die Beschriftung für die X-Achse liegen, in lines die darzustellenden Linien. Jede Linie besteht wiederum aus einem Titel und einem Array mit den Datenwerten.

Der Aufbau der Daten hängt natürlich hauptsächlich von der Datenquelle ab. Kommen die Daten in einem anderen Format aus der Quelle, können diese entweder vorher konvertiert werden, oder die Nutzung innerhalb von LineChart wird angepasst.

Schritt 4: Instanziierung des Objektes

Nichts, was wir in unsere Prototypen schreiben, wird ausgeführt. Zumindest nicht, bevor wir den Prototyp nicht tatsächlich nutzen. Deshalb fügen wir dem Code jetzt eine weitere Zeile hinzu, um den Prototypen zu instanziieren.

new LineChart(document.getElementById('chart'), data);

Mit dem Schlüsselwort new lassen wir den Konstruktor ein neues Objekt aus dem Prototypen instanziieren. Wir übergeben hier zum einen den Container, in welchen das Diagramm eingefügt werden soll, zum anderen das Objekt mit den Daten.

Schritt 5: Erstellen des SVG-Elements

Wir werden zum Darstellen des Diagramms ein SVG-Element benutzen. SVG ist vergleichbar mit dem HTML5 Canvas, es werden geometrische Formen, Texte etc. auf einer Arbeitsfläche abgebildet. Ein wichtiger Unterschied zum Canvas: Mit den Elementen in einem SVG kann der Nutzer interagieren. Auf alle Formen können EventListener gelegt werden, so kann beispielsweise beim Klick auf einen Datenpunkt eine Aktion ausgeführt werden und die Elemente können auf das Überfahren mit der Maus reagieren.

Da SVG nicht Bestandteil des HTML-Standards ist, müssen die SVG-Elemente mit einem speziellen Namespace erstellt werden. Dazu gibt es die Funktion document.createElementNS(). Um das erstellen von SVG-Elementen einfacher zu gestalten, schreiben wir dazu eine Helfer-Funktion. Da alle Diagramm-Typen diese Funktion nutzen werden, platzieren wir sie im Chart-Prototyp:

function Chart() {}

Chart.prototype = {
	createSvgElement: function(tag, attributes) {
		var element = document.createElementNS('http://www.w3.org/2000/svg', tag);
		for(var key in attributes) {
			element.setAttribute(key, attributes[key]);
		}
		return element;
	}
};

Im Konstruktor von LineChart können wir diese Funktion nun nutzen, um das SVG-Element zu generieren:

function LineChart(container, data) {
	Chart.call(this);
	
	var chart = this;
	
	chart.container = container;
	chart.labels = data.labels;
	chart.lines = data.lines;
	
	chart.svgElement = chart.createSvgElement('svg', {
		style: 'font-family: Arial, sans-serif;'
	});
	chart.container.appendChild(chart.svgElement);
}

Zu Anfang jeder Funktion innerhalb eines Prototyps kopiere ich meistens den Verweis this in eine separate Variable. Innerhalb der Funktion kann ich dann, auch in anonymen Funktionen, wo this gegebenenfalls auf ein anderes Objekt verweist, trotzdem noch auf das Objekt zugreifen. In diesen paar Zeilen Code ist das noch nicht nötig, aber spätestens wenn wir EventListener definieren, wird das nützlich werden.

Außerdem speichere ich die übergebenen Parameter als Variablen des Objektes, damit ich in anderen Funktionen des Objektes wieder darauf zugreifen kann.

Schließlich nutze ich die Funktion createSvgElement(), um das SVG-Element zu generieren und füge dies dann dem übergebenen Container mit appendChild() an.

Noch hat das Element keine definierte Größe. Diese definieren wir separat. Das macht Sinn, denn wir werden das jedes mal wieder tun, wenn sich die Fenstergröße ändert, um den jeweils verfügbaren Platz auszunutzen. Wird das Fenster schmaler, zeigen wir zum Beispiel weniger Beschriftung der X-Achse.

Im Prototyp legen wir deshalb eine Funktion an, die für das Rendern des Diagramms zuständig ist.

LineChart.prototype = Object.create(Chart.prototype, {
	constructor: {
		value: LineChart
	},
	render: {
		value: function() {
			
		}
	}
});

Im Konstruktor binden wir dann diese Funktion an das Resize-Event des Browser-Fensters. Damit wir das Diagramm nicht erst sehen, wenn der Nutzer sein Fenster tatsächlich vergrößert oder verkleinert, rufen wir diese Funktion auch noch direkt auf.

function LineChart(container, data) {
	Chart.call(this);
	
	var chart = this;
	
	chart.container = container;
	chart.labels = data.labels;
	chart.lines = data.lines;
	
	chart.svgElement = chart.createSvgElement('svg', {
		style: 'font-family: Arial, sans-serif;'
	});
	chart.container.appendChild(chart.svgElement);
	
	window.addEventListener('resize', function() {
		chart.render();
	});
	
	chart.render();
}

In der render()-Funktion werden zunächst nur die Dimensionen der SVG-Grafik definiert:

LineChart.prototype = Object.create(Chart.prototype, {
	constructor: {
		value: LineChart
	},
	render: {
		value: function() {
			var chart = this;
			
			chart.width = chart.container.clientWidth;
			chart.height = 500;
			
			chart.svgElement.setAttribute('width', chart.width);
			chart.svgElement.setAttribute('height', chart.height);
			chart.svgElement.setAttribute('viewBox', '0 0 ' + chart.width + ' ' + chart.height);
			
			while(chart.svgElement.childNodes.length) {
				chart.svgElement.removeChild(chart.svgElement.firstChild);
			}
		}
	}
});

Außer der Breite und Höhe setzen wir hier noch das Attribut viewBox. Mit viewBox wird definiert, wie das Koordinatensystem innerhalb des SVG-Elements auf die angezeigte SVG-Grafik übertragen werden. Da wir hier die Dimensionen der ViewBox genau gleich den Dimensionen der Grafik selbst gesetzt haben, werden die Koordinaten 1:1 übertragen, das heißt, ein Pixel am Bildschirm entspricht einem Pixel im SVG-Koordinatensystem.

Bevor wir neue Elemente im SVG-Element darstellen, entfernen wir alle alten Elemente. Dazu entfernen wir mit removeChild einfach so lange das erste Element im SVG, bis keine Elemente mehr vorhanden sind. Nun können wir uns an das Rendering der Elemente machen.

Schritt 6: Anzeigen der Legende

Als erstes werden wir die Legende auf den Bildschirm bringen. Dazu müssen wir als erstes die unterschiedlichen Farben für die Datensätze definieren.

Chart.prototype = {
	colors: ['#14568a', '#8a1616', '#dddb2f', '#1d7e07', '#2c4a4a'],
	createSvgElement: function(tag, attributes) {
		var element = document.createElementNS('http://www.w3.org/2000/svg', tag);
		for(var key in attributes) {
			element.setAttribute(key, attributes[key]);
		}
		return element;
	}
};

Im Array colors habe ich fünf verschiedene Farbtöne definiert, mit denen ich meine Linien färben kann. Die Farben speichere ich im Chart-Prototype, weil ich diese Farben auch für andere Diagramm-Typen nutzen werde.

Im LineChart-Prototype fügen wir eine neue Funktion renderLegend() hinzu und rufen diese am Ende der render()-Funktion auf:

render: {
	value: function() {
		var chart = this;
		
		chart.width = chart.container.clientWidth;
		chart.height = 500;
		
		chart.svgElement.setAttribute('width', chart.width);
		chart.svgElement.setAttribute('height', chart.height);
		chart.svgElement.setAttribute('viewBox', '0 0 ' + chart.width + ' ' + chart.height);
		
		while(chart.svgElement.childNodes.length) {
			chart.svgElement.removeChild(chart.svgElement.firstChild);
		}
		
		chart.renderLegend();
	}
},
renderLegend: {
	value: function() {
		var chart = this;
		
		chart.legend = chart.createSvgElement('g');
		chart.svgElement.appendChild(chart.legend);
	}
}

Wir erstellen ein neues G-Element – G–Elemente sind quasi Gruppen, welche sich gemeinsam beeinflussen lassen – für die Legende hinzu. Wir brauchen keine ID oder ähnliches, um das Element später wieder zu referenzieren, denn wir speichern uns einen Verweis direkt im Diagramm-Objekt unter chart.legend. Das Element fügen wir dann zum SVG-Element hinzu.

Anschließend fügen wir für jede Linie im Diagramm ein neues G-Element zur Legende hinzu.

var offset = 0;
for(var index = 0; index < chart.lines.length; index++) {
	var legendItemElement = chart.createSvgElement('g', {
		transform: 'translate(' + offset + ', 0)'
	});
	chart.legend.appendChild(legendItemElement);
}

Über das Attribut transform wenden wir eine Transformation auf die Gruppe an – in diesem Fall eine Verschiebung um offset auf der X-Achse und um 0 auf der Y-Achse. Mehr zu offset folgt gleich.

var offset = 0;
for(var index = 0; index < chart.lines.length; index++) {
	var legendItemElement = chart.createSvgElement('g', {
		transform: 'translate(' + offset + ', 0)'
	});
	chart.legend.appendChild(legendItemElement);
	
	var circleElement = chart.createSvgElement('circle', {
		cx: 10,
		cy: 8,
		r: 3.5,
		fill: chart.colors[index % chart.colors.length],
		stroke: 'none'
	});
	legendItemElement.appendChild(circleElement);
	
	var textElement = chart.createSvgElement('text', {
		x: 20,
		y: 13,
		'font-size': 14
	});
	legendItemElement.appendChild(textElement);
	
	textElement.textContent = chart.lines[index].title;
}

Jeder Eintrag in der Legende wird aus einem Kreis in der Farbe des Datensatzes und aus dem Titel des Datensatzes bestehen. Also generieren wir ein Circle-Element für den Kreis und ein Text-Element für den Titel.

Den Mittelpunkt des Kreises legen wir über cx und cy fest. Den Radius bestimmt r. Über fill legen wir fest, in welcher Farbe der Kreis gefüllt werden soll.

Die Position des Text-Elements legen wir über die Attribute x und y fest. Die Y-Position meint hier übrigens die Grundlinie des Textes. Das Attribut font-size legt die Schriftgröße fest. Der Text für das Text-Element stammt ist chart.lines[index].title.

Aktuell werden alle G-Elemente in der Legende noch an der gleichen Stelle angezeigt. Damit die Elemente korrekt nebeneinander stehen, müssen wir in jedem Schleifen-Durchlauf den Wert von offset um die Breite des aktuellen Elements erhöhen, damit das nächste Element entsprechend nach rechts verschoben wird.

offset += legendItemElement.getBBox().width + 20;

Mit der Funktion getBBox() können wir die Bounding-Box eines Elements, bestehend aus X- und Y-Position, Breite und Höhe, bestimmen. Die Breite dieser Bounding-Box addieren wir zum Offset, zusammen mit zusätzlichen 20 Pixeln als Abstand zwischen den Elementen.

Unser gesamter LineChart-Prototype sieht nun so aus:

function LineChart(container, data) {
	Chart.call(this);
	
	var chart = this;
	
	chart.container = container;
	chart.labels = data.labels;
	chart.lines = data.lines;
	
	chart.svgElement = chart.createSvgElement('svg', {
		style: 'font-family: Arial, sans-serif;'
	});
	chart.container.appendChild(chart.svgElement);
	
	window.addEventListener('resize', function() {
		chart.render();
	});
	
	chart.render();
}

LineChart.prototype = Object.create(Chart.prototype, {
	constructor: {
		value: LineChart
	},
	render: {
		value: function() {
			var chart = this;
			
			chart.width = chart.container.clientWidth;
			chart.height = 500;
			
			chart.svgElement.setAttribute('width', chart.width);
			chart.svgElement.setAttribute('height', chart.height);
			chart.svgElement.setAttribute('viewBox', '0 0 ' + chart.width + ' ' + chart.height);
			
			while(chart.svgElement.childNodes.length) {
				chart.svgElement.removeChild(chart.svgElement.firstChild);
			}
			
			chart.renderLegend();
		}
	},
	renderLegend: {
		value: function() {
			var chart = this;
			
			chart.legend = chart.createSvgElement('g');
			chart.svgElement.appendChild(chart.legend);
			
			var offset = 0;
			for(var index = 0; index < chart.lines.length; index++) {
				var legendItemElement = chart.createSvgElement('g', {
					transform: 'translate(' + offset + ', 0)'
				});
				chart.legend.appendChild(legendItemElement);
				
				var circleElement = chart.createSvgElement('circle', {
					cx: 10,
					cy: 8,
					r: 3.5,
					fill: chart.colors[index % chart.colors.length],
					stroke: 'none'
				});
				legendItemElement.appendChild(circleElement);
				
				var textElement = chart.createSvgElement('text', {
					x: 20,
					y: 13,
					'font-size': 14
				});
				legendItemElement.appendChild(textElement);
				
				textElement.textContent = chart.lines[index].title;
				
				offset += legendItemElement.getBBox().width + 20;
			}
		}
	}
});

Und so sieht unser Diagramm bis jetzt aus:

Schritt 7: Achsen, Abstände und Skalierung

Im nächsten Schritt generieren wir die Achsen. Hier wird es ein wenig komplizierter, da wir hier auch die Skalierung der Achsen und die für die Beschriftungen nötigen Abstände nach links und nach unten berechnen müssen.

Zunächst erstellen wir im LineChart-Prototype eine neue Funktion:

renderAxis: {
	value: function() {
		var chart = this;
	}
}

Diese Funktion rufen wir auch in der Funktion render() auf.

render: {
	value: function() {
		var chart = this;
		
		chart.width = chart.container.clientWidth;
		chart.height = 500;
		
		chart.svgElement.setAttribute('width', chart.width);
		chart.svgElement.setAttribute('height', chart.height);
		chart.svgElement.setAttribute('viewBox', '0 0 ' + chart.width + ' ' + chart.height);
		
		while(chart.svgElement.childNodes.length) {
			chart.svgElement.removeChild(chart.svgElement.firstChild);
		}
		
		chart.renderLegend();
		chart.renderAxis();
	}
},

In chart.axis werde ich alle berechneten Werte der Achsen speichern.

renderAxis: {
	value: function() {
		var chart = this;
		
		chart.axis = {
			x: {
				nthLabel: 1
			},
			y: {
				maximum: 1,
				step: 1
			}
		};
	}
}

Auf der X-Achse sollen alle verfügbaren Label aus chart.labels angezeigt werden. Abhängig von der Zahl der Labels und der Größe des Diagramms kann es aber vorkommen, dass nicht genug Platz für alle Beschriftungen verfügbar ist. In chart.axis.x.nthLabel lege ich daher fest, welche Label – angefangen beim ersten – angezeigt werden sollen. Nach Möglichkeit sollten natürlich alle Label angezeigt werden. Deshalb lege ich nthLabel zunächst auf 1 fest – es wird also jedes erste Label angezeigt. Wenn nicht genug Platz verfügbar ist, wird der Wert erhöht. Steht nthLabel zum Beispiel auf 3, wird nur jedes dritte Label angezeigt.

Für die Y-Achse legen wir zu Beginn zwei Variablen an. Die Variable maximum soll den Maximal-Wert beinhalten, der auf der Y-Achse abgebildet werden soll. In der Variablen step ist gespeichert, wie groß der Abstand der Beschriftungen auf der Y-Achse sein soll.

Momentan tragen diese drei Variablen noch alle den Wert 1. Hier werden wir im Folgenden die tatsächlichen Werte berechnen.

for(var lineIndex = 0; lineIndex < chart.lines.length; lineIndex++) {
	for(var datumIndex = 0; datumIndex < chart.lines[lineIndex].data.length; datumIndex++) {
		chart.axis.y.maximum = Math.max(chart.axis.y.maximum, chart.lines[lineIndex].data[datumIndex
]);
	}
}

Wir gehen als erstes alle Datenpunkte aller Datensätze durch und speichern das Maximum in der Variable chart.axis.y.maximum.

Als nächstes ermitteln wir anhand des Maximums eine sinnvolle Einteilung der Y-Achse. Mein erster Ansatz war, einfach das Maximum durch fünf zu teilen. Das führt aber leider zu unschön krummen Beschriftungen wie 478, wo beispielsweise 500 viel sinnvoller wäre.

while(chart.axis.y.step * 7 < chart.axis.y.maximum) {
	chart.axis.y.step *= 10;
}
if(chart.axis.y.step % 2 === 0 && chart.axis.y.step * 3.5 > chart.axis.y.maximum) {
	chart.axis.y.step /= 2;
}

Schließlich habe ich das Problem so gelöst: step beginnt mit einem Wert von 1. Solange mit dem aktuellen Wert von step das Maximum nicht mit sieben Beschriftungen auf der Y-Achse erreicht werden kann, wird step mit 10 multipliziert. So landen wir schonmal bei runden Beschriftungen, die immer ein Vielfaches von 1, 10, 100, 1.000 usw. sind. Anschließend prüfen wir noch, ob das Maximum auch mit der halben Schritt-Größe erreicht werden kann – denn Beschriftungen wie 50, 150 oder 2.500 wären ja auch noch rund. Gegebenenfalls können wir deshalb step halbieren.

chart.axis.y.maximum = Math.ceil(chart.axis.y.maximum / chart.axis.y.step) * chart.axis.y.step;

Hier legen wir noch das tatsächliche Maximum des Diagramms anhand der eben ermittelten Schritt-Größe fest.

Als nächstes müssen wir ermitteln, wie viel Abstand die Achsen nach links und nach unten benötigen, damit die Beschriftungen genügend Platz haben. Dazu benötigen wir als erstes eine Hilfsfunktion, die Zahlen mit dem korrekten Tausender-Trennzeichen ausstattet.

function Chart() {}

Chart.prototype = {
	colors: ['#14568a', '#8a1616', '#dddb2f', '#1d7e07', '#2c4a4a'],
	createSvgElement: function(tag, attributes) {
		var element = document.createElementNS('http://www.w3.org/2000/svg', tag);
		for(var key in attributes) {
			element.setAttribute(key, attributes[key]);
		}
		return element;
	},
	formatNumber: function(number) {
		var string = '';
		
		number = number + '';
		while(number.length > 3) {
			string = '.' + number.substr(number.length - 3) + string;
			number = number.substr(0, number.length - 3);
		}
		string = number + string;
		
		return string;
	}
};

Da auch diese Funktion in allen Diagramm-Typen Anwendung findet, erstellen wir die Funktion formatNumber() im Chart-Prototyp.

Die Erstellung der Labels für die Achsen lagern wir auch in eigene Funktionen innerhalb des LineChart-Prototyps aus:

renderXAxisLabel: {
	value: function(label) {
		var chart = this;
		
		var labelElement = chart.createSvgElement('g');
		
		var lineElement = chart.createSvgElement('line', {
			x1: 0,
			y1: 0,
			x2: 0,
			y2: 5,
			stroke: '#c0c0c0'
		});
		labelElement.appendChild(lineElement);
		
		var textElement = chart.createSvgElement('text', {
			x: -15,
			y: 7,
			'font-size': 11,
			'text-anchor': 'end',
			transform: 'rotate(-45 0 0)'
		});
		textElement.textContent = label;
		labelElement.appendChild(textElement);
		
		return labelElement;
	}
},
renderYAxisLabel: {
	value: function(label) {
		var chart = this;
		
		var labelElement = chart.createSvgElement('g');
		
		var lineElement = chart.createSvgElement('line', {
			x1: 0,
			y1: 0,
			x2: chart.width,
			y2: 0,
			stroke: '#c0c0c0'
		});
		labelElement.appendChild(lineElement);
		
		var textElement = chart.createSvgElement('text', {
			x: -8,
			y: 4,
			'font-size': 11,
			'text-anchor': 'end'
		});
		textElement.textContent = label;
		labelElement.appendChild(textElement);
		
		return labelElement;
	}
}

Beide Funktionen erstellen ein neues G-Element, legen in dem Element jeweils eine Linie und einen Text an und geben das G-Element dann zurück. Die Funktionen ordnen die Linien und Texte unterschiedlich an, für die Y-Achse werden horizontale Linien über das gesamte Diagramm gezogen, bei der X-Achse sind die Linien nur kurz und vertikal und markieren auf der X-Achse die Position des Labels.

Beide Texte werden mit dem Attribut text-anchor="end" ausgestattet, was zur Folge hat, dass die Position des Textes sich am Ende des Textes orientiert. Die Texte sind also rechtsbündig und enden jeweils an dem durch x und y definierten Punkt.

Der Text an der X-Achse wird zudem noch um 45° rotiert, sodass die Label diagonal nach oben laufen.

Die G-Elemente, welche diese Funktionen zurückgeben, liegen dann alle im Ursprung. Nachdem wir den benötigten Platz ermittelt haben, können wir jedes G-Element an die korrekte Position transformieren.

Aber zunächst generieren wir die Achsen und Label. Dazu fügen wir in der Funktion renderAxis() – direkt nach der Ermittlung von chart.axis.y.step und des neuen Maximums – folgenden Code hinzu:

chart.axis.x.element = chart.createSvgElement('g');
chart.svgElement.appendChild(chart.axis.x.element);

chart.axis.y.element = chart.createSvgElement('g');
chart.svgElement.appendChild(chart.axis.y.element);

for(var index = 0; index < chart.labels.length; index++) {
	chart.axis.x.element.appendChild(chart.renderXAxisLabel(chart.labels[index]));
}

for(var y = 0; y <= chart.axis.y.maximum; y += chart.axis.y.step) {
	chart.axis.y.element.appendChild(chart.renderYAxisLabel(chart.formatNumber(y)));
}

Dank der eben definierten Hilfs-Funktionen ist dieser Code relativ simpel. Wir legen zwei neue G-Elemente – eins für jede Achse – an und speichern diese in dem jeweiligen Achsen-Objekt in chart.axis.x.element beziehungsweise chart.axis.y.element. Diese Elemente fügen wir dann dem SVG-Element hinzu.

Anschließen fügen wir an die X-Achse alle Labels aus chart.labels an. Die Label für die Y-Achse bestimmen wir aus chart.axis.y.step und chart.axis.y.maximum.

Wie gesagt sind die Achsen und Beschriftungen jetzt noch nicht am richtigen Platz, aber mit getBBox() können wir jetzt die Größe der einzelnen Elemente ermitteln.

chart.padding = {
	top: chart.legend.getBBox().height + 20,
	left: -1 * Math.min(chart.axis.x.element.getBBox().x + 20, chart.axis.y.element.getBBox().x - 8),
	bottom: chart.axis.x.element.getBBox().height
};

In chart.padding können wir nun die benötigten Abstände speichern. Der Abstand nach oben hängt von der Größe der Legende ab. Hier geben wir zusätzlich 20 Pixel Abstand zur Legende.

Der Abstand nach links errechnet sich aus der aktuellen Position der gerade erstellten Achsen und Beschrifungen. Da sich die Texte rechts anordnen, ragen sie momentan, wo die Achsen noch alle in der oberen linken Ecke liegen, nach links aus dem SVG-Element heraus. getBBox().x liefert uns hier also einen negativen Wert als X-Position. Mit Math.min ermitteln wir, welches Element am weitesten aus dem Bild herausragt und nutzen diesen Wert – mit positivem Vorzeichen – als linken Abstand.

Der Abstand nach unten ist einfach die Höhe der X-Achse samt Beschriftung.

Jetzt wissen wir genau, wie groß das Diagramm sein wird. Als nächstes können wir nun die Achsen und Beschriftungen korrekt positionieren.

chart.axis.x.element.setAttribute('transform', 'translate(0, ' + (chart.height - chart.padding.bottom) + ')');
chart.axis.x.stepSize = chart.labels.length > 1 ? (chart.width - chart.padding.left - 40) / (chart.labels.length - 1) : 20;

Zuerst transformieren wir die X-Achse an die korrekte Position. Da wir jetzt die Breite des Diagramms bestimmen können, können wir auch stepSize – den Abstand der Beschriftungen auf der X-Achse – errechnen. Hier gibt es einen Sonderfall: Wir können den Abstand natürlich nur bestimmen, wenn es mindestens zwei Beschriftungen gibt. Sind weniger als zwei Beschriftungen vorhanden, definieren wir den Abstand einfach als 20 Pixel.

Als nächstes stellen wir sicher, dass der Abstand zwischen den Beschriftungen nie kleiner als 20 Pixel ist.

while(chart.axis.x.stepSize * chart.axis.x.nthLabel < 20) {
	chart.axis.x.nthLabel++;
}

Dazu erhöhen wir nthLabel so lange, bis der Abstand ausreicht. Gegebenfalls zeigen wir also nur jede zweite, dritte, vierte Beschriftung usw.

Wenn wir nicht jede Beschriftung anzeigen können, müssen wir die nicht angezeigten Beschriftungen nun löschen:

if(chart.axis.x.nthLabel > 1) {
	for(var index = chart.axis.x.element.childNodes.length - 1; index > 0; index--) {
		if(index % chart.axis.x.nthLabel) {
			chart.axis.x.element.removeChild(chart.axis.x.element.childNodes[index]);
		}
	}
}

Schließlich können wir die verbleibenden Beschriftungen an die korrekte Position transformieren:

for(var index = 0; index < chart.axis.x.element.childNodes.length; index++) {
	chart.axis.x.element.childNodes[index].setAttribute('transform', 'translate(' + (chart.padding.left + 20 + chart.axis.x.nthLabel * index * chart.axis.x.stepSize) + ', 0)');
}

Die Beschriftungen der X-Achse sind nun an Ort und Stelle:

Auch die Y-Achse können wir nun korrekt transformieren:

chart.axis.y.element.setAttribute('transform', 'translate(' + chart.padding.left + ', 0)');
chart.axis.y.stepSize = (500 - chart.padding.bottom - chart.padding.top) / (chart.axis.y.maximum / chart.axis.y.step);
for(var index = 0; index < chart.axis.y.element.childNodes.length; index++) {
	chart.axis.y.element.childNodes[index].setAttribute('transform', 'translate(0, ' + (500 - chart.padding.bottom - index * chart.axis.y.stepSize) + ')');
}

Wieder wird zunächst die gesamte Achse transformiert, anschließend berechnen wir auch hier den Abstand zwischen den Beschriftungen und korrigieren dann die Position jeder Beschriftung.

Damit sind jetzt beide Achsen korrekt positioniert:

Schritt 8: Punkte und Linien

Abschließend müssen wir nur noch die tatsächlichen Datenpunkte im Diagramm einfügen. Auch dafür erstellen wir eine neue Funktion im LineChart-Prototypen:

renderLines: {
	value: function() {
		var chart = this;
	}
}

Auch diese Funktion rufen wir in der Funktion render() auf:

render: {
	value: function() {
		var chart = this;
		
		chart.width = chart.container.clientWidth;
		chart.height = 500;
		
		chart.svgElement.setAttribute('width', chart.width);
		chart.svgElement.setAttribute('height', chart.height);
		chart.svgElement.setAttribute('viewBox', '0 0 ' + chart.width + ' ' + chart.height);
		
		while(chart.svgElement.childNodes.length) {
			chart.svgElement.removeChild(chart.svgElement.firstChild);
		}
		
		chart.renderLegend();
		chart.renderAxis();
		chart.renderLines();
	}
},

In der Funktion renderLines() legen wir nun für jeden Datensatz eine eigene Gruppe an:

renderLines: {
	value: function() {
		var chart = this;
		
		for(var lineIndex = 0; lineIndex < chart.lines.length; lineIndex++) {
			chart.lines[lineIndex].element = chart.createSvgElement('g', {
				transform: 'translate(' + (chart.padding.left + 20) + ', ' + (500 - chart.padding.bottom) + ')'
			});
			chart.svgElement.appendChild(chart.lines[lineIndex].element);
		}
	}
}

Nun zur Darstellung der Daten: Erstens soll jeder Datenpunkt mit einem Punkt dargestellt werden, zweitens sollen alle Punkte mit einer Linie verbunden werden.

renderLines: {
	value: function() {
		var chart = this;
		
		for(var lineIndex = 0; lineIndex < chart.lines.length; lineIndex++) {
			chart.lines[lineIndex].element = chart.createSvgElement('g', {
				transform: 'translate(' + (chart.padding.left + 20) + ', ' + (500 - chart.padding.bottom) + ')'
			});
			chart.svgElement.appendChild(chart.lines[lineIndex].element);
			
			var points = [];
			chart.lines[lineIndex].circles = [];
			
			for(var datumIndex = 0; datumIndex < chart.lines[lineIndex].data.length; datumIndex++) {
				var point = {
					x: datumIndex * chart.axis.x.stepSize,
					y: -1 * chart.lines[lineIndex].data[datumIndex] * (chart.axis.y.stepSize / chart.axis.y.step),
				};
				points.push(point.x + ',' + point.y);
				
				
			}
		}
	}
}

Wir legen deshalb gleich zwei Arrays an, points für die Punkte der Linie, circles für die Kreis-Elemente, mit denen wir die Punkte darstellen.

Aus stepSize beider Achsen lässt sich unkompliziert die tatsächliche Position eines Punktes feststellen. In points speichern wir beide Koordinaten als String, diese Koordinaten werden wir später einem Polyline-Element übergeben.

for(var datumIndex = 0; datumIndex < chart.lines[lineIndex].data.length; datumIndex++) {
	var point = {
		x: datumIndex * chart.axis.x.stepSize,
		y: -1 * chart.lines[lineIndex].data[datumIndex] * (chart.axis.y.stepSize / chart.axis.y.step),
	};
	points.push(point.x + ',' + point.y);
	
	if(chart.axis.x.stepSize >= 10) {
		var circleElement = chart.createSvgElement('circle', {
			cx: point.x,
			cy: point.y,
			r: 3.5,
			fill: chart.colors[lineIndex % chart.colors.length],
			stroke: 'none'
		});
		chart.lines[lineIndex].element.appendChild(circleElement);
		chart.lines[lineIndex].circles.push(circleElement);
	}
}

Den Punkt selbst zeigen wir nur, wenn dafür genug Platz ist – sprich, wenn stepSize größer oder gleich 10 ist.

In diesem Fall legen wir dann ein neues Circle-Element in der Farbe des Datensatzes an und fügen es in die Gruppe und in das Array circles ein.

Nachdem wir alle Punkte erstellt und die Koordinaten in points gesammelt haben, können wir nun abschließend noch die Linie hinzufügen:

var lineElement = chart.createSvgElement('polyline', {
	points: points.join(' '),
	stroke: chart.colors[lineIndex % chart.colors.length],
	'stroke-width': 2,
	fill: 'none'
});
chart.lines[lineIndex].element.appendChild(lineElement);
chart.lines[lineIndex].line = lineElement;

Da wir in points schon die X- und Y-Koordinaten mit Kommata getrennt gesammelt haben, müssen wir das Array hier nur noch mit join(' ') zusammenführen. Auch die Linie wird in das SVG eingefügt.

Fertiges Diagramm

Damit kommen wir beim Endresultat an:

Und hier zum Abschluss noch einmal der gesamte Code:

<!DOCTYPE html>
<html lang="de">
<head>
	<meta charset="UTF-8">
	<title>Lininen-Diagramm</title>
</head>
<body>
	<div id="chart">
	
	</div>
	
	<script type="text/javascript" src="js/chart.js"></script>
	<script type="text/javascript" src="js/line-chart.js"></script>
	<script type="text/javascript" src="js/main.js"></script>
</body>
</html>
function Chart() {}

Chart.prototype = {
	colors: ['#14568a', '#8a1616', '#dddb2f', '#1d7e07', '#2c4a4a'],
	createSvgElement: function(tag, attributes) {
		var element = document.createElementNS('http://www.w3.org/2000/svg', tag);
		for(var key in attributes) {
			element.setAttribute(key, attributes[key]);
		}
		return element;
	},
	formatNumber: function(number) {
		var string = '';
		
		number = number + '';
		while(number.length > 3) {
			string = '.' + number.substr(number.length - 3) + string;
			number = number.substr(0, number.length - 3);
		}
		string = number + string;
		
		return string;
	}
};
function LineChart(container, data) {
	Chart.call(this);
	
	var chart = this;
	
	chart.container = container;
	chart.labels = data.labels;
	chart.lines = data.lines;
	
	chart.svgElement = chart.createSvgElement('svg', {
		style: 'font-family: Arial, sans-serif;'
	});
	chart.container.appendChild(chart.svgElement);
	
	window.addEventListener('resize', function() {
		chart.render();
	});
	
	chart.render();
}

LineChart.prototype = Object.create(Chart.prototype, {
	constructor: {
		value: LineChart
	},
	render: {
		value: function() {
			var chart = this;
			
			chart.width = chart.container.clientWidth;
			chart.height = 500;
			
			chart.svgElement.setAttribute('width', chart.width);
			chart.svgElement.setAttribute('height', chart.height);
			chart.svgElement.setAttribute('viewBox', '0 0 ' + chart.width + ' ' + chart.height);
			
			while(chart.svgElement.childNodes.length) {
				chart.svgElement.removeChild(chart.svgElement.firstChild);
			}
			
			chart.renderLegend();
			chart.renderAxis();
			chart.renderLines();
		}
	},
	renderLegend: {
		value: function() {
			var chart = this;
			
			chart.legend = chart.createSvgElement('g');
			chart.svgElement.appendChild(chart.legend);
			
			var offset = 0;
			for(var index = 0; index < chart.lines.length; index++) {
				var legendItemElement = chart.createSvgElement('g', {
					transform: 'translate(' + offset + ', 0)'
				});
				chart.legend.appendChild(legendItemElement);
				
				var circleElement = chart.createSvgElement('circle', {
					cx: 10,
					cy: 8,
					r: 3.5,
					fill: chart.colors[index % chart.colors.length],
					stroke: 'none'
				});
				legendItemElement.appendChild(circleElement);
				
				var textElement = chart.createSvgElement('text', {
					x: 20,
					y: 13,
					'font-size': 14
				});
				legendItemElement.appendChild(textElement);
				
				textElement.textContent = chart.lines[index].title;
				
				offset += legendItemElement.getBBox().width + 20;
			}
		}
	},
	renderAxis: {
		value: function() {
			var chart = this;
			
			chart.axis = {
				x: {
					nthLabel: 1
				},
				y: {
					maximum: 1,
					step: 1
				}
			};
			
			for(var lineIndex = 0; lineIndex < chart.lines.length; lineIndex++) {
				for(var datumIndex = 0; datumIndex < chart.lines[lineIndex].data.length; datumIndex++) {
					chart.axis.y.maximum = Math.max(chart.axis.y.maximum, chart.lines[lineIndex].data[datumIndex]);
				}
			}
			
			while(chart.axis.y.step * 7 < chart.axis.y.maximum) {
				chart.axis.y.step *= 10;
			}
			if(chart.axis.y.step % 2 === 0 && chart.axis.y.step * 3.5 > chart.axis.y.maximum) {
				chart.axis.y.step /= 2;
			}
			
			chart.axis.y.maximum = Math.ceil(chart.axis.y.maximum / chart.axis.y.step) * chart.axis.y.step;
			
			chart.axis.x.element = chart.createSvgElement('g');
			chart.svgElement.appendChild(chart.axis.x.element);
			
			chart.axis.y.element = chart.createSvgElement('g');
			chart.svgElement.appendChild(chart.axis.y.element);
			
			for(var index = 0; index < chart.labels.length; index++) {
				chart.axis.x.element.appendChild(chart.renderXAxisLabel(chart.labels[index]));
			}
			
			for(var y = 0; y <= chart.axis.y.maximum; y += chart.axis.y.step) {
				chart.axis.y.element.appendChild(chart.renderYAxisLabel(chart.formatNumber(y)));
			}
			
			chart.padding = {
				top: chart.legend.getBBox().height + 20,
				left: -1 * Math.min(chart.axis.x.element.getBBox().x + 20, chart.axis.y.element.getBBox().x - 8),
				bottom: chart.axis.x.element.getBBox().height
			};
			
			chart.axis.x.element.setAttribute('transform', 'translate(0, ' + (chart.height - chart.padding.bottom) + ')');
			chart.axis.x.stepSize = chart.labels.length > 1 ? (chart.width - chart.padding.left - 40) / (chart.labels.length - 1) : 20;
			
			while(chart.axis.x.stepSize * chart.axis.x.nthLabel < 20) {
				chart.axis.x.nthLabel++;
			}
			
			if(chart.axis.x.nthLabel > 1) {
				for(var index = chart.axis.x.element.childNodes.length - 1; index > 0; index--) {
					if(index % chart.axis.x.nthLabel) {
						chart.axis.x.element.removeChild(chart.axis.x.element.childNodes[index]);
					}
				}
			}
			
			for(var index = 0; index < chart.axis.x.element.childNodes.length; index++) {
				chart.axis.x.element.childNodes[index].setAttribute('transform', 'translate(' + (chart.padding.left + 20 + chart.axis.x.nthLabel * index * chart.axis.x.stepSize) + ', 0)');
			}
			
			chart.axis.y.element.setAttribute('transform', 'translate(' + chart.padding.left + ', 0)');
			chart.axis.y.stepSize = (500 - chart.padding.bottom - chart.padding.top) / (chart.axis.y.maximum / chart.axis.y.step);
			for(var index = 0; index < chart.axis.y.element.childNodes.length; index++) {
				chart.axis.y.element.childNodes[index].setAttribute('transform', 'translate(0, ' + (500 - chart.padding.bottom - index * chart.axis.y.stepSize) + ')');
			}
		}
	},
	renderXAxisLabel: {
		value: function(label) {
			var chart = this;
			
			var labelElement = chart.createSvgElement('g');
			
			var lineElement = chart.createSvgElement('line', {
				x1: 0,
				y1: 0,
				x2: 0,
				y2: 5,
				stroke: '#c0c0c0'
			});
			labelElement.appendChild(lineElement);
			
			var textElement = chart.createSvgElement('text', {
				x: -15,
				y: 7,
				'font-size': 11,
				'text-anchor': 'end',
				transform: 'rotate(-45 0 0)'
			});
			textElement.textContent = label;
			labelElement.appendChild(textElement);
			
			return labelElement;
		}
	},
	renderYAxisLabel: {
		value: function(label) {
			var chart = this;
			
			var labelElement = chart.createSvgElement('g');
			
			var lineElement = chart.createSvgElement('line', {
				x1: 0,
				y1: 0,
				x2: chart.width,
				y2: 0,
				stroke: '#c0c0c0'
			});
			labelElement.appendChild(lineElement);
			
			var textElement = chart.createSvgElement('text', {
				x: -8,
				y: 4,
				'font-size': 11,
				'text-anchor': 'end'
			});
			textElement.textContent = label;
			labelElement.appendChild(textElement);
			
			return labelElement;
		}
	},
	renderLines: {
		value: function() {
			var chart = this;
			
			for(var lineIndex = 0; lineIndex < chart.lines.length; lineIndex++) {
				chart.lines[lineIndex].element = chart.createSvgElement('g', {
					transform: 'translate(' + (chart.padding.left + 20) + ', ' + (500 - chart.padding.bottom) + ')'
				});
				chart.svgElement.appendChild(chart.lines[lineIndex].element);
				
				var points = [];
				chart.lines[lineIndex].circles = [];
				
				for(var datumIndex = 0; datumIndex < chart.lines[lineIndex].data.length; datumIndex++) {
					var point = {
						x: datumIndex * chart.axis.x.stepSize,
						y: -1 * chart.lines[lineIndex].data[datumIndex] * (chart.axis.y.stepSize / chart.axis.y.step),
					};
					points.push(point.x + ',' + point.y);
					
					if(chart.axis.x.stepSize >= 10) {
						var circleElement = chart.createSvgElement('circle', {
							cx: point.x,
							cy: point.y,
							r: 3.5,
							fill: chart.colors[lineIndex % chart.colors.length],
							stroke: 'none'
						});
						chart.lines[lineIndex].element.appendChild(circleElement);
						chart.lines[lineIndex].circles.push(circleElement);
					}
				}
				
				var lineElement = chart.createSvgElement('polyline', {
					points: points.join(' '),
					stroke: chart.colors[lineIndex % chart.colors.length],
					'stroke-width': 2,
					fill: 'none'
				});
				chart.lines[lineIndex].element.appendChild(lineElement);
				chart.lines[lineIndex].line = lineElement;
			}
		}
	}
});
var weekdays = ['So', 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa'];
var months = ['Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember'];
var from = new Date(2015, 8, 21-28, 0, 0, 0);
var to = new Date(2015, 8, 21, 0, 0, 0);

var data = {
	labels: [],
	lines: [{
		title: 'Messwerte 1',
		data: []
	}, {
		title: 'Messwerte 2',
		data: []
	}]
};

while(from <= to) {
	data.labels.push(weekdays[from.getDay()] + ', ' + from.getDate() + '. ' + months[from.getMonth()] + ' ' + from.getFullYear());
	
	for(var lineIndex = 0; lineIndex < data.lines.length; lineIndex++) {
		data.lines[lineIndex].data.push(Math.round(lineIndex * 1000 + 500 + (Math.random() + Math.random()) * 500));
	}
	
	from.setDate(from.getDate() + 1);
}

new LineChart(document.getElementById('chart'), data);

Noch ist das Diagramm statisch und reagiert nicht auf den Nutzer. Im nächsten Teil der Artikelserie wirst Du erfahren, wie Du dem Diagramm EventListener hinzufügen kannst und so auf Nutzer-Interaktionen reagieren kannst.

Nächster Artikel der Serie

Dieser Artikel ist Teil der Artikelserie »Interaktive SVG-Diagramme im Web«.

Hier geht es zum nächsten Artikel der Serie: Events im Diagramm – Interaktive SVG-Diagramme im Web