Aufbau eines Donut-Diagramms mit Vue und SVG

Avatar of Salomone Baquis
Salomone Baquis am

DigitalOcean bietet Cloud-Produkte für jede Phase Ihrer Reise. Starten Sie mit 200 $ kostenlosem Guthaben!

Mmm… verbotener Donut.“

– Homer Simpson

Ich musste kürzlich ein Donut-Diagramm für ein Reporting-Dashboard bei der Arbeit erstellen. Der Mock-up, den ich bekam, sah ungefähr so aus

Mein Diagramm hatte ein paar grundlegende Anforderungen. Es musste

  • Seine Segmente dynamisch basierend auf einer beliebigen Menge von Werten berechnen
  • Beschriftungen haben
  • Gut skalierbar über alle Bildschirmgrößen und Geräte hinweg
  • Browserübergreifend kompatibel bis zurück zu Internet Explorer 11
  • Barrierefrei sein
  • Wiederverwendbar im Vue.js Frontend meiner Arbeit sein

Ich wollte auch etwas haben, das ich später animieren konnte, wenn nötig. All dies klang nach einer Aufgabe für SVG.

SVGs sind out-of-the-box barrierefrei (das W3C hat einen ganzen Abschnitt dazu) und können durch zusätzliche Eingaben noch barrierefreier gestaltet werden. Und da sie auf Daten basieren, sind sie ein perfekter Kandidat für dynamische Visualisierungen.

Es gibt zahlreiche Artikel zu diesem Thema, darunter zwei von Chris (hier und hier) und ein ganz neuer von Burke Holland. Ich habe D3 für dieses Projekt nicht verwendet, da die Anwendung den Overhead dieser Bibliothek nicht benötigte.

Ich habe das Diagramm als Vue-Komponente für mein Projekt erstellt, aber Sie könnten dies genauso gut mit reinem JavaScript, HTML und CSS machen.

Hier ist das fertige Produkt

Das Rad (des Kreises) neu erfinden

Wie jeder sich selbst respektierende Entwickler habe ich als Erstes gegoogelt, ob das nicht schon jemand anderes gemacht hat. Dann habe ich, wie derselbe Entwickler, die vorgefertigte Lösung zugunsten meiner eigenen verworfen.

Der Top-Treffer für „SVG-Donut-Diagramm“ ist dieser Artikel, der beschreibt, wie man stroke-dasharray und stroke-dashoffset verwendet, um mehrere überlagerte Kreise zu zeichnen und die Illusion eines einzelnen segmentierten Kreises zu erzeugen (mehr dazu gleich).

Das Overlay-Konzept gefällt mir sehr gut, aber ich fand das Neuberechnen von stroke-dasharray und stroke-dashoffset verwirrend. Warum nicht einen festen stroke-dasharray-Wert festlegen und dann jeden Kreis mit einer transform drehen? Außerdem musste ich jedem Segment Beschriftungen hinzufügen, was im Tutorial nicht behandelt wurde.

Eine Linie zeichnen

Bevor wir ein dynamisches Donut-Diagramm erstellen können, müssen wir zuerst verstehen, wie das Zeichnen von SVG-Linien funktioniert. Wenn Sie Jake Archibalds exzellente Animated Line Drawing in SVG noch nicht gelesen haben. Chris hat auch eine gute Übersicht.

Diese Artikel liefern die meiste benötigte Kontextinformation, aber kurz gesagt, SVG hat zwei Präsentationsattribute: stroke-dasharray und stroke-dashoffset.

stroke-dasharray definiert ein Array aus Strichen und Lücken, das zum Zeichnen der Umrisslinie einer Form verwendet wird. Es kann null, einen oder zwei Werte annehmen. Der erste Wert definiert die Strichlänge; der zweite die Lückenlänge.

stroke-dashoffset definiert hingegen, wo die Menge der Striche und Lücken beginnt. Wenn die Werte von stroke-dasharray und stroke-dashoffset die Länge der Linie sind und gleich sind, ist die gesamte Linie sichtbar, da wir dem Offset (wo der Strich-Array beginnt) sagen, am Ende der Linie zu beginnen. Wenn stroke-dasharray die Länge der Linie ist, aber stroke-dashoffset 0 ist, dann ist die Linie unsichtbar, da wir den gerenderten Teil des Strichs um seine gesamte Länge versetzen.

Chris‘ Beispiel demonstriert dies schön

Wie wir das Diagramm aufbauen

Um die Segmente des Donut-Diagramms zu erstellen, werden wir für jedes Segment einen separaten Kreis erstellen, die Kreise übereinander legen und dann stroke, stroke-dasharray und stroke-dashoffset verwenden, um nur einen Teil der Umrisslinie jedes Kreises anzuzeigen. Wir werden dann jeden sichtbaren Teil in die richtige Position drehen, wodurch die Illusion einer einzigen Form entsteht. Während wir dies tun, werden wir auch die Koordinaten für die Textbeschriftungen berechnen.

Hier ist ein Beispiel, das diese Rotationen und Überlagerungen demonstriert

Grundlegende Einrichtung

Beginnen wir mit der Einrichtung unserer Struktur. Ich benutze x-template zu Demozwecken, aber für die Produktion würde ich empfehlen, eine Single-File-Komponente zu erstellen.

<div id="app">
  <donut-chart></donut-chart>
</div>
<script type="text/x-template" id="donutTemplate">
  <svg height="160" width="160" viewBox="0 0 160 160">
    <g v-for="(value, index) in initialValues">
      <circle :cx="cx" :cy="cy" :r="radius" fill="transparent" :stroke="colors[index]" :stroke-width="strokeWidth" ></circle>
      <text></text>
    </g>
  </svg>
</script>
Vue.component('donutChart', {
  template: '#donutTemplate',
  props: ["initialValues"],
  data() {
    return {
      chartData: [],
      colors: ["#6495ED", "goldenrod", "#cd5c5c", "thistle", "lightgray"],
      cx: 80,
      cy: 80,                      
      radius: 60,
      sortedValues: [],
      strokeWidth: 30,    
    }
  }  
})
new Vue({
  el: "#app",
  data() {
    return {
      values: [230, 308, 520, 130, 200]
    }
  },
});

Damit

  • Erstellen unserer Vue-Instanz und unserer Donut-Chart-Komponente, und dann weisen wir unserer Donut-Komponente an, einige Werte (unser Datenset) als Props zu erwarten
  • Festlegen unserer grundlegenden SVG-Formen: für die Segmente und für die Beschriftungen, mit den grundlegenden Abmessungen, Strichstärke und Farben definiert
  • Diese Formen in ein -Element einschließen, das sie zusammenfasst
  • Eine v-for-Schleife zum g>-Element hinzufügen, die wir verwenden werden, um jeden Wert zu durchlaufen, den die Komponente erhält
  • Erstellen eines leeren sortedValues-Arrays, das wir verwenden werden, um eine sortierte Version unserer Daten zu speichern
  • Erstellen eines leeren chartData-Arrays, das unsere Hauptpositionierungsdaten enthalten wird

Kreislänge

Unser stroke-dasharray sollte die Länge des gesamten Kreises sein, was uns eine einfache Basiszahl gibt, die wir zur Berechnung jedes stroke-dashoffset-Werts verwenden können. Denken Sie daran, dass die Länge eines Kreises sein Umfang ist und die Formel für den Umfang 2πr lautet (das erinnern Sie sich doch, oder?).

Wir können dies zu einer berechneten Eigenschaft in unserer Komponente machen.

computed: {
  circumference() {
    return 2 * Math.PI * this.radius
  }
}

…und den Wert an unser Template-Markup binden.

<svg height="160" width="160" viewBox="0 0 160 160">
  <g v-for="(value, index) in initialValues">
    <circle :cx="cx" :cy="cy" :r="radius" fill="transparent" :stroke="colors[index]" :stroke-width="strokeWidth" :stroke-dasharray="circumference"></circle>
    <text></text>
  </g>
</svg>

Im ursprünglichen Mock-up sahen wir, dass die Segmente vom größten zum kleinsten gingen. Wir können eine weitere berechnete Eigenschaft erstellen, um diese zu sortieren. Wir speichern die sortierte Version im sortedValues-Array.

sortInitialValues() {
  return this.sortedValues = this.initialValues.sort((a,b) => b-a)
}

Schließlich, damit diese sortierten Werte vor dem Rendern des Diagramms für Vue verfügbar sind, werden wir diese berechnete Eigenschaft aus dem mounted()-Lifecycle-Hook aufrufen.

mounted() {
  this.sortInitialValues                
}

Im Moment sieht unser Diagramm so aus

Keine Segmente. Nur ein durchgehend gefärbter Donut. Wie HTML werden SVG-Elemente in der Reihenfolge gerendert, in der sie im Markup erscheinen. Die erscheinende Farbe ist die Umrissfarbe des letzten Kreises im SVG. Da wir noch keine stroke-dashoffset-Werte hinzugefügt haben, geht die Umrisslinie jedes Kreises einmal komplett herum. Lassen Sie uns dies beheben, indem wir Segmente hinzufügen.

Segmente erstellen

Um jedes der Kreissegmente zu erhalten, müssen wir

  1. Den Prozentsatz jedes Datenwerts von der Gesamtsumme der übergebenen Datenwerte berechnen
  2. Diesen Prozentsatz mit dem Umfang multiplizieren, um die Länge des sichtbaren Strichs zu erhalten
  3. Diese Länge vom Umfang abziehen, um den stroke-offset zu erhalten

Das klingt komplizierter, als es ist. Beginnen wir mit einigen Hilfsfunktionen. Zuerst müssen wir unsere Datenwerte aufsummieren. Wir können dies mit einer berechneten Eigenschaft tun.

dataTotal() {
  return this.sortedValues.reduce((acc, val) => acc + val)
},

Um den Prozentsatz jedes Datenwerts zu berechnen, müssen wir Werte aus der v-for-Schleife übergeben, die wir zuvor erstellt haben. Das bedeutet, wir brauchen eine Methode.

methods: {
  dataPercentage(dataVal) {
    return dataVal / this.dataTotal
  }
},

Wir haben nun genügend Informationen, um unsere stroke-offset-Werte zu berechnen, die unsere Kreissegmente definieren werden.

Auch hier wollen wir: (a) unseren Datenprozentsatz mit dem Kreisumfang multiplizieren, um die Länge des sichtbaren Strichs zu erhalten, und (b) diese Länge vom Umfang abziehen, um den stroke-offset zu erhalten.

Hier ist die Methode, um unsere stroke-offsets zu erhalten

calculateStrokeDashOffset(dataVal, circumference) {
  const strokeDiff = this.dataPercentage(dataVal) * circumference
  return circumference - strokeDiff
},

…was wir mit folgendem an unseren Kreis im HTML binden

:stroke-dashoffset="calculateStrokeDashOffset(value, circumference)"

Und voilà! Wir sollten etwas wie das hier haben

Segmente drehen

Nun zum spaßigen Teil. Alle Segmente beginnen um 3 Uhr, was der Standard-Startpunkt für SVG-Kreise ist. Um sie an die richtige Stelle zu bringen, müssen wir jedes Segment in seine richtige Position drehen.

Dies können wir erreichen, indem wir das Verhältnis jedes Segments zu 360 Grad ermitteln und diesen Betrag um die bisher aufsummierten Gesamtdrehungen versetzen.

Fügen wir zuerst eine Datenvariable hinzu, um den Offset zu speichern

angleOffset: -90,

Dann unsere Berechnung (das ist eine berechnete Eigenschaft)

calculateChartData() {
  this.sortedValues.forEach((dataVal, index) => {
    const data = {
      degrees: this.angleOffset,
    }
    this.chartData.push(data)
    this.angleOffset = this.dataPercentage(dataVal) * 360 + this.angleOffset
  })
},

Jede Iteration erstellt ein neues Objekt mit einer „degrees“-Eigenschaft, pusht dieses in unser chartValues-Array, das wir zuvor erstellt haben, und aktualisiert dann den angleOffset für die nächste Iteration.

Aber Moment mal, was ist mit dem -90-Wert?

Nun, wenn wir uns unseren ursprünglichen Mock-up ansehen, ist das erste Segment bei der 12-Uhr-Position dargestellt, also -90 Grad vom Startpunkt aus. Indem wir unseren angleOffset auf -90 setzen, stellen wir sicher, dass unser größtes Donut-Segment oben beginnt.

Um diese Segmente im HTML zu drehen, verwenden wir das transform Präsentationsattribut mit der rotate-Funktion. Erstellen wir eine weitere berechnete Eigenschaft, damit wir einen schönen, formatierten String zurückgeben können.

returnCircleTransformValue(index) {
  return `rotate(${this.chartData[index].degrees}, ${this.cx}, ${this.cy})`
},

Die rotate-Funktion nimmt drei Argumente: einen Drehwinkel und x- und y-Koordinaten, um die herum gedreht wird. Wenn wir keine cx- und cy-Koordinaten angeben, werden unsere Segmente um das gesamte SVG-Koordinatensystem gedreht.

Als Nächstes binden wir dies an unsere Kreismarkierung.

:transform="returnCircleTransformValue(index)"

Und da wir all diese Berechnungen durchführen müssen, bevor das Diagramm gerendert wird, fügen wir unsere calculateChartData berechnete Eigenschaft im mounted-Hook hinzu

mounted() {
  this.sortInitialValues
  this.calculateChartData
}

Schließlich, wenn wir den schönen, süßen Abstand zwischen jedem Segment haben wollen, können wir zwei vom Umfang abziehen und diesen als unser neues stroke-dasharray verwenden.

adjustedCircumference() {
  return this.circumference - 2
},
:stroke-dasharray="adjustedCircumference"

Segmente, Baby!

Beschriftungen

Wir haben unsere Segmente, aber jetzt brauchen wir Beschriftungen. Das bedeutet, dass wir unsere -Elemente mit x- und y-Koordinaten an verschiedenen Punkten entlang des Kreises platzieren müssen. Sie vermuten vielleicht, dass dies Mathematik erfordert. Leider haben Sie Recht.

Glücklicherweise ist das nicht die Art von Mathematik, bei der wir reale Konzepte anwenden müssen; es ist eher die Art, bei der wir Formeln googeln und nicht zu viele Fragen stellen.

Laut dem Internet sind die Formeln zur Berechnung von x- und y-Punkten auf einem Kreis

x = r cos(t) + a
y = r sin(t) + b

…wobei r der Radius, t der Winkel und a und b die x- und y-Mittelpunktverschiebungen sind.

Das meiste davon haben wir bereits: Wir kennen unseren Radius, wir wissen, wie wir unsere Winkel für die Segmente berechnen können, und wir kennen unsere Mittelpunktverschiebungs-Werte (cx und cy).

Es gibt jedoch einen Haken: In diesen Formeln ist t in *Radiant* angegeben. Wir arbeiten in Grad, was bedeutet, dass wir einige Umwandlungen vornehmen müssen. Wiederum führt eine schnelle Suche zu einer Formel

radians = degrees * (π / 180)

…was wir in einer Methode darstellen können

degreesToRadians(angle) {
  return angle * (Math.PI / 180)
},

Wir haben nun genügend Informationen, um unsere x- und y-Koordinaten für den Text zu berechnen

calculateTextCoords(dataVal, angleOffset) {
  const angle = (this.dataPercentage(dataVal) * 360) / 2 + angleOffset
  const radians = this.degreesToRadians(angle)

  const textCoords = {
    x: (this.radius * Math.cos(radians) + this.cx),
    y: (this.radius * Math.sin(radians) + this.cy)
  }
  return textCoords
},

Zuerst berechnen wir den Winkel unseres Segments, indem wir das Verhältnis unseres Datenwerts mit 360 multiplizieren; wir wollen jedoch eigentlich die *Hälfte* davon, da unsere Textbeschriftungen in der Mitte des Segments und nicht am Ende liegen. Wir müssen den Winkel-Offset hinzufügen, wie wir es bei der Erstellung der Segmente getan haben.

Unsere calculateTextCoords-Methode kann nun in der berechneten Eigenschaft calculateChartData verwendet werden

calculateChartData() {
  this.sortedValues.forEach((dataVal, index) => {
    const { x, y } = this.calculateTextCoords(dataVal, this.angleOffset)        
    const data = {
      degrees: this.angleOffset,
      textX: x,
      textY: y
    }
    this.chartData.push(data)
    this.angleOffset = this.dataPercentage(dataVal) * 360 + this.angleOffset
  })
},

Fügen wir auch eine Methode hinzu, um den Beschriftungsstring zurückzugeben

percentageLabel(dataVal) {
  return `${Math.round(this.dataPercentage(dataVal) * 100)}%`
},

Und im Markup

<text :x="chartData[index].textX" :y="chartData[index].textY">{{ percentageLabel(value) }}</text>

Jetzt haben wir Beschriftungen

Igitt, so dezentriert. Das können wir mit dem text-anchor Präsentationsattribut beheben. Abhängig von Ihrer Schriftart und font-size möchten Sie die Positionierung möglicherweise auch anpassen. Schauen Sie sich dx und dy dazu an.

Überarbeitetes Textelement

<text text-anchor="middle" dy="3px" :x="chartData[index].textX" :y="chartData[index].textY">{{ percentageLabel(value) }}</text>

Hm, es scheint, dass bei kleinen Prozentsätzen die Beschriftungen außerhalb der Segmente liegen. Fügen wir eine Methode hinzu, um dies zu überprüfen.

segmentBigEnough(dataVal) {
  return Math.round(this.dataPercentage(dataVal) * 100) > 5
}
<text v-if="segmentBigEnough(value)" text-anchor="middle" dy="3px" :x="chartData[index].textX" :y="chartData[index].textY">{{ percentageLabel(value) }}</text>

Nun fügen wir Beschriftungen nur zu Segmenten hinzu, die größer als 5 % sind.

Und wir sind fertig! Wir haben nun eine wiederverwendbare Donut-Chart-Komponente, die jeden Satz von Werten akzeptieren und Segmente erstellen kann. Super cool!

Das fertige Produkt

Nächste Schritte

Es gibt viele Möglichkeiten, dies jetzt, da es erstellt ist, zu ändern oder zu verbessern. Zum Beispiel

  • Hinzufügen von Elementen zur Verbesserung der Barrierefreiheit, wie <title> und <desc> Tags, Aria-Labels und Aria-Rollenattribute.
  • Erstellen von Animationen mit CSS oder Bibliotheken wie Greensock, um auffällige Effekte zu erzielen, wenn das Diagramm in Sicht kommt.
  • Spielen mit Farbschemata.<title> und <desc> Tags, Aria-Labels und Aria-Rollenattribute.</li> <li>Erstellen von Animationen mit CSS oder Bibliotheken wie <a href="https://greensock.com/">Greensock</a>, um auffällige Effekte zu erzielen, wenn das Diagramm in Sicht kommt.</li> <li>Spielen mit Farbschemata.</li> </ul> <p>Ich würde mich freuen zu hören, was Sie von dieser Implementierung halten und welche anderen Erfahrungen Sie mit SVG-Diagrammen gemacht haben. Teilen Sie es in den Kommentaren mit!</p>

Ich würde mich freuen zu hören, was Sie von dieser Implementierung halten und welche anderen Erfahrungen Sie mit SVG-Diagrammen gemacht haben. Teilen Sie es in den Kommentaren mit!