Events im Diagramm – Interaktive SVG-Diagramme im Web

Events im Diagramm – Interaktive SVG-Diagramme im Web
Von Lars Ebert am 24. 10. 2016, 11:00 Uhr
Kategorien: JavaScript, Programmieren und Tutorials

Nachdem wir im letzten Artikel der Artikelserie ein Linien-Diagramm erstellt haben, kümmern wir uns in diesem Artikel darum, das Diagramm für den Nutzer interaktiv zu gestalten. Hier punktet ein SVG-Diagramm gegenüber einem Canvas oder gar einer Bilddatei: Wir können dem Nutzer recht einfach die Möglichkeit geben, mit dem Diagramm zu interagieren, um zum Beispiel genaue Werte abzulesen oder in eine tiefere Detail-Ansicht zu gelangen.

Im Folgenden gehe ich die Änderungen Schritt für Schritt durch.

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

Das Resultat

So wird das Diagramm am Ende des Artikels aussehen. Optisch ändert sich nicht viel, allerdings kann der Nutzer nun mit der Maus über die Punkte fahren und sie anklicken.

Ausgangssituation

Im vorherigen Artikel haben wir schon ein Linien-Diagramm erstellt, dort findest Du auch den aktuellen Stand des Quellcodes. So sieht unser Diagramm momentan aus:

Tooltips zeigen detaillierte Daten

Die erste neue Funktion für unser interaktives Diagramm werden Tooltips sein. Beim überfahren mit der Maus wollen wir dem Nutzer die genauen Zahlen zeigen, die sich hinter dem Messwert verbergen. Bewegt der Nutzer die Maus über eine Spalte (also einen Tag), werden für diesen Tag die genauen Zahlen aller Messreihen angezeigt.

Dafür müssen wir als erstes die Position der Maus verfolgen.

function LineChart(container, data) {
	Chart.call(this);
	
	var chart = this;
	
	chart.container = container;
	chart.labels = data.labels;
	chart.lines = data.lines;
	chart.tooltip = null;
	
	chart.svgElement = chart.createSvgElement('svg', {
		style: 'font-family: Arial, sans-serif;'
	});
	chart.container.appendChild(chart.svgElement);
	
	window.addEventListener('resize', function() {
		chart.render();
	});
	
	chart.svgElement.addEventListener('mousemove', function(event) {
		
	});
	chart.svgElement.addEventListener('mouseleave', function(event) {
		// Tooltip löschen
	});
	
	chart.render();
}

Da die einzelnen Elemente im SVG reguläre DOM-Elemente sind, können wir ihnen auch einfach EventListener verpassen. Über das mousemove Event prüfen wir beim Bewegen der Maus, ob ein Tooltip angezeigt werden muss, beim mouseleave Event wird der Tooltip dann wieder entfernt.

var chartBounds = chart.svgElement.getBoundingClientRect();
chartBounds.top += document.body.scrollTop;
chartBounds.left += document.body.scrollLeft;
var mouse = {
	x: event.pageX - chartBounds.left,
	y: event.pageY - chartBounds.top
};

Im mousemove Event können wir die aktuelle Position der Maus leicht bestimmen. getBoundingClientRect() gibt die Position des Diagramms im Browserfenster zurück, diese muss dann allerdings noch mit der Scroll-Position verrechnet werden, um absolute Zahlen zu bekommen. In mouse speichern wir schließlich die Mausposition relativ zur linken oberen Ecke des Diagramms.

if(mouse.x > chart.padding.left && mouse.y > chart.padding.top && mouse.y < chart.height - chart.padding.bottom) {
	
} else {
	// Tooltip löschen
}

Der Tooltip soll nur angezeigt werden, wenn die Maus sich über der tatsächlichen Diagramm-Fläche befindet, aber nicht über den Beschriftungen oder der Legende. Daher prüfen wir nun, ob die Mausposition innerhalb der Diagramm-Fläche liegt.

var datumIndex = Math.max(0, Math.min(chart.labels.length - 1, Math.round((mouse.x - chart.padding.left - 20) / chart.axis.x.stepSize)));

Anschließend rechnen wir aus, über welcher Spalte sich die Maus befindet. Da die Punkte auf der X-Achse immer den gleichen Abstand haben, können wir das mit einer einfachen Formel erledigen.

if(chart.tooltip === null || chart.tooltip.datumIndex !== datumIndex) {
	
}

Nun noch ein Sonderfall: Wenn der richtige Tooltip schon angezeigt wird, brauchen wir ihn nicht neu anzeigen. Daher werden wir später das aktive Tooltip in chart.tooltip speichern und gleichen hier den Spalten-Index ab. So müssen wir den Tooltip nur neu darstellen, wenn es wirklich nötig ist.

chart.tooltip = {
	datumIndex: datumIndex,
	element: chart.createSvgElement('g', {
		'pointer-events': 'none'
	}),
	line: chart.createSvgElement('line', {
		x1: chart.padding.left + 20 + datumIndex * chart.axis.x.stepSize,
		y1: chart.padding.top,
		x2: chart.padding.left + 20 + datumIndex * chart.axis.x.stepSize,
		y2: chart.height - chart.padding.bottom,
		stroke: '#c0c0c0'
	})
};
chart.svgElement.insertBefore(chart.tooltip.line, chart.svgElement.firstChild);
chart.svgElement.appendChild(chart.tooltip.element);

Jetzt können wir das Tooltip-Element schon hinzufügen. Genau genommen fügen wir zwei Elemente hinzu: einmal den Tooltip selbst, und eine Linie, die dem Nutzer zeigt, in welcher Spalte er sich gerade befindet.

Wir speichern die beiden Elemente und den Spalten-Index in chart.tooltip, damit wir später noch darauf zugreifen können.

Das Tooltip-Element ist bisher einfach nur eine leere Gruppe. Das Linien-Element ist eine line. Die Linie geht vom oberen bis zum unteren Rand der Diagrammfläche.

Damit die Linie im Hintergrund liegt, fügen wir sie an den Anfang des SVG-Elements hinzu. Der Tooltip soll im Vordergrund liegen, daher wird er ans Ende eingefügt.

removeTooltip: {
	value: function() {
		var chart = this;
		
		if(chart.tooltip !== null) {
			chart.svgElement.removeChild(chart.tooltip.element);
			chart.svgElement.removeChild(chart.tooltip.line);
			chart.tooltip = null;
		}
	}
}

Wir schreiben jetzt noch schnell eine Funktion, die die Tooltip-Elemente wieder entfernt. Diese können wir dann an den entsprechenden Stellen einfügen.

Die Linie verhält sich schon wie erwartet, also stimmt bis hierhin schonmal alles. Ein kleiner Zwischenstand:

function LineChart(container, data) {
	Chart.call(this);
	
	var chart = this;
	
	chart.container = container;
	chart.labels = data.labels;
	chart.lines = data.lines;
	chart.tooltip = null;
	
	chart.svgElement = chart.createSvgElement('svg', {
		style: 'font-family: Arial, sans-serif;'
	});
	chart.container.appendChild(chart.svgElement);
	
	window.addEventListener('resize', function() {
		chart.render();
	});
	
	chart.svgElement.addEventListener('mousemove', function(event) {
		var chartBounds = chart.svgElement.getBoundingClientRect();
		chartBounds.top += document.body.scrollTop;
		chartBounds.left += document.body.scrollLeft;
		
		var mouse = {
			x: event.pageX - chartBounds.left,
			y: event.pageY - chartBounds.top
		};
		
		if(mouse.x > chart.padding.left && mouse.y > chart.padding.top && mouse.y < chart.height - chart.padding.bottom) {
			var datumIndex = Math.max(0, Math.min(chart.labels.length - 1, Math.round((mouse.x - chart.padding.left - 20) / chart.axis.x.stepSize)));
			
			if(chart.tooltip === null || chart.tooltip.datumIndex !== datumIndex) {
				chart.removeTooltip();
				
				chart.tooltip = {
					datumIndex: datumIndex,
					element: chart.createSvgElement('g', {
						'pointer-events': 'none'
					}),
					line: chart.createSvgElement('line', {
						x1: chart.padding.left + 20 + datumIndex * chart.axis.x.stepSize,
						y1: chart.padding.top,
						x2: chart.padding.left + 20 + datumIndex * chart.axis.x.stepSize,
						y2: chart.height - chart.padding.bottom,
						stroke: '#c0c0c0'
					})
				};
				
				chart.svgElement.insertBefore(chart.tooltip.line, chart.svgElement.firstChild);
				chart.svgElement.appendChild(chart.tooltip.element);
			}
		} else {
			chart.removeTooltip();
		}
	});
	chart.svgElement.addEventListener('mouseleave', function(event) {
		chart.removeTooltip();
	});
	
	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;
			}
		}
	},
	removeTooltip: {
		value: function() {
			var chart = this;
			
			if(chart.tooltip !== null) {
				chart.svgElement.removeChild(chart.tooltip.element);
				chart.svgElement.removeChild(chart.tooltip.line);
				chart.tooltip = null;
			}
		}
	}
});

Als nächstes brauchen wir das Tooltip-Element.

var textElement = chart.createSvgElement('text', {
	x: 10,
	y: 21,
	'font-size': 12
});
textElement.textContent = chart.labels[datumIndex];
chart.tooltip.element.appendChild(textElement);

Die erste Zeile im Tooltip soll das Label der Spalte sein, also das formatierte Datum.

for(var lineIndex = 0; lineIndex < chart.lines.length; lineIndex++) {
	var textElement = chart.createSvgElement('text', {
		x: 10,
		y: 21 + (lineIndex + 1) * 16,
		'font-size': 12
	});
	chart.tooltip.element.appendChild(textElement);
	
	var titleElement = chart.createSvgElement('tspan', {
		fill: chart.colors[lineIndex % chart.colors.length]
	});
	textElement.appendChild(titleElement);
	titleElement.textContent = chart.lines[lineIndex].title + ': ';
	
	var valueElement = chart.createSvgElement('tspan', {
		'font-weight': 'bold'
	});
	textElement.appendChild(valueElement);
	valueElement.textContent = chart.formatNumber(chart.lines[lineIndex].data[datumIndex]);
}

Außerdem wollen wir die Daten jeder Datenreihe darstellen. Dafür zeigen wir den Namen der Datenreihe und den Wert der Datenreihe in der aktiven Spalte. Um den Namen der Datenreihe einzufärben, nutzen wir tspan Elemente.

var box = chart.tooltip.element.getBBox();
var boxElement = chart.createSvgElement('rect', {
	x: 0,
	y: 0,
	width: box.width + 20,
	height: box.height + 20,
	rx: 3,
	ry: 3,
	fill: 'rgba(255, 255, 255, .9)',
	stroke: '#c0c0c0'
});
chart.tooltip.element.insertBefore(boxElement, chart.tooltip.element.firstChild);

Außerdem brauchen wir noch eine Box im Hintergrund, um dem Tooltip einen Hintergrund und Rahmen zu geben.

var left = chart.padding.left + 20 + datumIndex * chart.axis.x.stepSize - box.width - 30;
var top = mouse.y - (box.height + 20) / 2;
if(left < 0) {
	left += box.width + 40;
}
top = Math.max(chart.padding.top, Math.min(500 - chart.padding.bottom - box.height - 20, top));
chart.tooltip.element.setAttribute('transform', 'translate(' + left + ', ' + top + ')');

Nun zur Position des Tooltips: Die vertikale Position machen wir vom Mauszeiger abhängig. So sollte der Tooltip immer auf einer Höhe mit dem Mauszeiger sein. Die horizontale Position berechnen wir anhand der Spalte, damit der Tooltip immer links neben der vertikalen Linie steht.

Sonderfall: Wenn der Tooltip links aus dem Diagramm herausragen würde, positionieren wir es stattdessen rechts von der Linie.

Hover und Klick

Nehmen wir an, hinter den einzelnen Messpunkten gibt es weitere Informationen. Beispielsweise könnte sich beim Klick auf den Datenpunkt eine genaue Auswertung oder eine Übersicht über den Tag öffnen. Daher machen wir die Datenpunkte jetzt einfach klickbar. Und damit man sieht, dass beim Klicken was passiert, heben wir die Punkte beim Hover etwas hervor.

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',
						cursor: 'pointer'
					});
					chart.lines[lineIndex].element.appendChild(circleElement);
					chart.lines[lineIndex].circles.push(circleElement);
					
					circleElement.addEventListener('mouseenter', function(event) {
						event.target.setAttribute('r', 4.5);
					});
					circleElement.addEventListener('mouseleave', function(event) {
						event.target.setAttribute('r', 3.5);
					});
					circleElement.addEventListener('click', function(event) {
						// Klick-Event
					});
				}
			}
			
			var lineElement = chart.createSvgElement('polyline', {
				points: points.join(' '),
				stroke: chart.colors[lineIndex % chart.colors.length],
				'stroke-width': 2,
				fill: 'none'
			});
			chart.lines[lineIndex].element.insertBefore(lineElement, chart.lines[lineIndex].element.firstChild);
			chart.lines[lineIndex].line = lineElement;
		}
	}
},

In der Funktion renderLines() hängen wir an die Punkte drei EventListener an: mouseenter, mouseleave und click. Beim mouseenter vergrößern wir den Radius des Punktes, beim mouseleave verkleinern wir ihn wieder auf den Originalwert. So wird der Punkt beim Überfahren mit der Maus hervorgehoben. Nebenbei geben wir den Punkten noch die Eigenschaft cursor: 'pointer'.

Außerdem passen wir den Code noch etwas an, damit die Linie vor den Punkten eingefügt wird, statt hinter den Punkten.

Im Klick-Event könnten wir nun noch den Klick auf den Punkt abfangen, um beispielsweise auf eine Detailseite weiterzuleiten. Da wir aber keine Detailseite haben, spare ich mir das an dieser Stelle.

Fazit: SVG ist super für interaktivität

So sieht nun unser Endresultat aus. Der Nutzer kann nun mit dem Diagramm interagieren.

SVG-Grafiken sind super für Interaktivität. Auf die Elemente kann man mit JavaScript zugreifen und ihnen EventListeners anhängen. So kann man in diesem Fall Diagrammen sehr einfach einen Mehrwert geben.

Im nächsten Artikel schauen wir uns einen neuen Diagramm-Typen an.