Wer liebt Diagramme? Jeder, oder? Es gibt viele Möglichkeiten, sie zu erstellen, darunter auch eine Reihe von Bibliotheken. Es gibt D3.js, Chart.js, amCharts, Highcharts und Chartist, um nur einige der vielen, vielen Optionen zu nennen.
Aber wir brauchen nicht unbedingt eine Diagrammbibliothek, um Diagramme zu erstellen. Nehmen wir Mobx-state-tree (MST), eine intuitive Alternative zu Redux zur Verwaltung von Zuständen in React. Wir können ein interaktives benutzerdefiniertes Diagramm mit einfachen SVG-Elementen erstellen und MST zur Verwaltung und Manipulation der Daten für das Diagramm verwenden. Wenn Sie in der Vergangenheit versucht haben, Diagramme mit etwas wie D3.js zu erstellen, werden Sie diesen Ansatz wahrscheinlich intuitiver finden. Selbst wenn Sie ein erfahrener D3.js-Entwickler sind, werden Sie wahrscheinlich daran interessiert sein zu sehen, wie leistungsfähig MST als Datenarchitektur für Visualisierungen sein kann.
Hier ist ein Beispiel für die Verwendung von MST zur Befütterung eines Diagramms
Dieses Beispiel verwendet die Skalierungsfunktionen von D3, aber das Diagramm selbst wird einfach mit SVG-Elementen innerhalb von JSX gerendert. Ich kenne keine Diagrammbibliothek, die eine Option für blinkende Hamsterpunkte hat. Das ist also ein großartiges Beispiel dafür, warum es toll ist, eigene Diagramme zu erstellen – und es ist nicht so schwer, wie Sie vielleicht denken!
Ich erstelle seit über 10 Jahren Diagramme mit D3 und obwohl ich liebe, wie leistungsfähig es ist, habe ich immer festgestellt, dass mein Code unhandlich und schwer zu warten werden kann, insbesondere bei komplexen Visualisierungen. MST hat all das komplett verändert, indem es eine elegante Möglichkeit bietet, die Datenverarbeitung von der Darstellung zu trennen. Meine Hoffnung ist, dass dieser Artikel Sie ermutigen wird, es auszuprobieren.
Mit dem MST-Modell vertraut machen
Zunächst einmal werfen wir einen kurzen Überblick darüber, wie ein MST-Modell aussieht. Dies ist kein ausführlicher Leitfaden zu allen Dingen rund um MST. Ich möchte nur die Grundlagen zeigen, denn wirklich, das ist alles, was Sie ungefähr 90% der Zeit brauchen.
Unten sehen Sie eine Sandbox mit dem Code für eine einfache To-do-Liste, die in MST erstellt wurde. Werfen Sie einen schnellen Blick darauf und dann erkläre ich, was jeder Abschnitt tut.
Zunächst wird die Form des Objekts mit typisierten Definitionen der Attribute des Modells definiert. In einfacher Sprache bedeutet dies, dass eine Instanz des To-do-Modells einen Titel haben muss, der ein String sein muss, und standardmäßig ein "done"-Attribut von false haben wird.
.model("Todo", {
title: types.string,
done: false //this is equivalent to types.boolean that defaults to false
})
Als Nächstes haben wir die View- und Action-Funktionen. View-Funktionen sind Möglichkeiten, auf berechnete Werte zuzugreifen, die auf Daten innerhalb des Modells basieren, ohne Daten im Modell zu verändern. Sie können sich das als schreibgeschützte Funktionen vorstellen.
.views(self => ({
outstandingTodoCount() {
return self.todos.length - self.todos.filter(t => t.done).length;
}
}))
Action-Funktionen hingegen erlauben uns, die Daten sicher zu aktualisieren. Dies geschieht immer im Hintergrund auf eine nicht-veränderliche Weise.
.actions(self => ({
addTodo(title) {
self.todos.push({
id: Math.random(),
title
});
}
}));
Schließlich erstellen wir eine neue Instanz des Stores
const todoStore = TodoStore.create({
todos: [
{
title: "foo",
done: false
}
]
});
Um den Store in Aktion zu zeigen, habe ich ein paar Konsolenprotokolle hinzugefügt, um die Ausgabe von outStandingTodoCount() vor und nach dem Auslösen der Toggle-Funktion der ersten Instanz eines Todo zu zeigen.
console.log(todoStore.outstandingTodoCount()); // outputs: 1
todoStore.todos[0].toggle();
console.log(todoStore.outstandingTodoCount()); // outputs: 0
Wie Sie sehen können, gibt uns MST eine Datenstruktur, mit der wir leicht auf Daten zugreifen und diese manipulieren können. Wichtiger ist, dass die Struktur sehr intuitiv ist und der Code auf einen Blick leicht zu lesen ist – kein einziger Reducer in Sicht!
Lassen Sie uns eine React-Chart-Komponente erstellen
OK, nachdem wir nun ein wenig Hintergrundwissen darüber haben, wie MST aussieht, wollen wir es nutzen, um einen Store zu erstellen, der die Daten für ein Diagramm verwaltet. Wir beginnen jedoch mit dem Chart-JSX, da es viel einfacher ist, den Store zu erstellen, sobald man weiß, welche Daten benötigt werden.
Schauen wir uns das JSX an, das das Diagramm rendert.
Das erste, was auffällt, ist, dass wir styled-components zur Organisation unseres CSS verwenden. Wenn Ihnen das neu ist, hat Cliff Hall einen großartigen Beitrag, der die Verwendung mit einer React-App zeigt.
Zunächst rendern wir die Dropdown-Liste, die die Diagrammachsen ändern wird. Dies ist eine ziemlich einfache HTML-Dropdown-Liste, die in eine Styled Component verpackt ist. Beachten Sie, dass es sich um ein kontrolliertes Eingabeelement handelt, dessen Zustand mit dem Wert selectedAxes aus unserem Modell gesetzt wird (wir werden uns das später ansehen).
<select
onChange={e =>
model.setSelectedAxes(parseInt(e.target.value, 10))
}
defaultValue={model.selectedAxes}
>
Als Nächstes haben wir das Diagramm selbst. Ich habe die Achsen und Punkte in eigene Komponenten aufgeteilt, die in einer separaten Datei liegen. Das hilft wirklich, den Code wartbar zu halten, indem jede Datei schön klein bleibt. Außerdem bedeutet dies, dass wir die Achsen wiederverwenden können, wenn wir zum Beispiel ein Liniendiagramm statt Punkten haben wollen. Das zahlt sich wirklich aus, wenn man an großen Projekten mit verschiedenen Diagrammtypen arbeitet. Es macht es auch einfach, die Komponenten isoliert zu testen, sowohl programmatisch als auch manuell innerhalb eines lebendigen Styleguides.
{model.ready ? (
<div>
<Axes
yTicks={model.getYAxis()}
xTicks={model.getXAxis()}
xLabel={xAxisLabels[model.selectedAxes]}
yLabel={yAxisLabels[model.selectedAxes]}
></Axes>
<Points points={model.getPoints()}></Points>
</div>
) : (
<Loading></Loading>
)}
Versuchen Sie, die Achsen- und Punktekomponenten in der obigen Sandbox auszukommentieren, um zu sehen, wie sie unabhängig voneinander funktionieren.
Schließlich verpacken wir die Komponente mit einer Observer-Funktion. Das bedeutet, dass jede Änderung im Modell ein erneutes Rendern auslöst.
export default observer(HeartrateChart);
Werfen wir einen Blick auf die Axes-Komponente
Wie Sie sehen, haben wir eine XAxis und eine YAxis. Jede hat eine Beschriftung und eine Reihe von Markierungen. Wie die Markierungen erstellt werden, behandeln wir später, aber hier sollten Sie beachten, dass jede Achse aus einer Reihe von Ticks besteht, die durch Abbilden eines Arrays von Objekten mit einer Beschriftung und entweder einem x- oder einem y-Wert erzeugt werden, abhängig davon, welche Achse wir rendern.
Versuchen Sie, einige der Attributwerte für die Elemente zu ändern und sehen Sie, was passiert... oder kaputt geht! Ändern Sie zum Beispiel das Linien-Element in der YAxis zu folgendem
<line x1={30} x2="95%" y1={0} y2={y} />
Der beste Weg, um zu lernen, wie man Visualisierungen mit SVG erstellt, ist einfach zu experimentieren und Dinge kaputt zu machen. 🙂
Okay, das ist die Hälfte des Diagramms. Jetzt schauen wir uns die Points-Komponente an.
Jeder Punkt im Diagramm besteht aus zwei Dingen: einem SVG-Bild und einem Kreiselement. Das Bild ist das Tier-Symbol und der Kreis sorgt für die Pulsanimation, die beim Überfahren des Symbols mit der Maus sichtbar ist.
Versuchen Sie, das image-Element und dann das circle-Element auszukommentieren, um zu sehen, was passiert.
Diesmal muss das Modell ein Array von Punktobjekten bereitstellen, das uns vier Eigenschaften gibt: x- und y-Werte zur Positionierung des Punktes im Diagramm, eine label für den Punkt (den Namen des Tieres) und pulse, was die Dauer der Pulsanimation für jedes Tier-Symbol ist. Hoffentlich scheint das alles intuitiv und logisch.
Auch hier gilt: Versuchen Sie, mit Attributwerten zu spielen, um zu sehen, was sich ändert und kaputt geht. Sie können versuchen, das y-Attribut des image auf 0 zu setzen. Glauben Sie mir, dies ist eine weitaus weniger einschüchternde Art zu lernen, als die W3C-Spezifikation für ein SVG-Bild-Element zu lesen!
Hoffentlich gibt Ihnen das ein Verständnis und ein Gefühl dafür, wie wir das Diagramm in React rendern. Jetzt geht es nur noch darum, ein Modell mit den entsprechenden Aktionen zu erstellen, um die Punkt- und Tick-Daten zu generieren, über die wir in JSX iterieren müssen.
Erstellen unseres Stores
Hier ist der vollständige Code für den Store
Ich werde den Code in die drei zuvor genannten Teile aufteilen
Definieren der Attribute des Modells
Alles, was wir hier definieren, ist extern als Eigenschaft der Modellinstanz zugänglich und – wenn eine observable-umwickelte Komponente verwendet wird – lösen Änderungen an diesen Eigenschaften ein erneutes Rendern aus.
.model('ChartModel', {
animals: types.array(AnimalModel),
paddingAndMargins: types.frozen({
paddingX: 30,
paddingRight: 0,
marginX: 30,
marginY: 30,
marginTop: 30,
chartHeight: 500
}),
ready: false, // means a types.boolean that defaults to false
selectedAxes: 0 // means a types.number that defaults to 0
})
Jedes Tier hat vier Datenpunkte: Name (Creature), Langlebigkeit (Longevity__Years_), Gewicht (Mass__grams_) und Ruheherzfrequenz (Resting_Heart_Rate__BPM_).
const AnimalModel = types.model('AnimalModel', {
Creature: types.string,
Longevity__Years_: types.number,
Mass__grams_: types.number,
Resting_Heart_Rate__BPM_: types.number
});
Definieren der Aktionen
Wir haben nur zwei Aktionen. Die erste (setSelectedAxes) wird aufgerufen, wenn das Dropdown-Menü geändert wird, was das Attribut selectedAxes aktualisiert, welches wiederum bestimmt, welche Daten zum Rendern der Achsen verwendet werden.
setSelectedAxes(val) {
self.selectedAxes = val;
},
Die Aktion setUpScales erfordert eine etwas genauere Erklärung. Diese Funktion wird kurz nach dem Mounten der Chart-Komponente, innerhalb einer useEffect-Hook-Funktion, oder nach einer Größenänderung des Fensters aufgerufen. Sie akzeptiert ein Objekt mit der Breite des DOM, das das Element enthält. Dies ermöglicht es uns, die Skalierungsfunktionen für jede Achse so einzurichten, dass sie die volle verfügbare Breite ausfüllen. Ich werde die Skalierungsfunktionen kurz erläutern.
Um Skalierungsfunktionen einzurichten, müssen wir den Maximalwert für jeden Datentyp berechnen. Daher durchlaufen wir zunächst die Tiere, um diese Maximal- und Minimalwerte zu berechnen. Wir können Null als Minimalwert für jede Skala verwenden, die bei Null beginnen soll.
// ...
self.animals.forEach(
({
Creature,
Longevity__Years_,
Mass__grams_,
Resting_Heart_Rate__BPM_,
...rest
}) => {
maxHeartrate = Math.max(
maxHeartrate,
parseInt(Resting_Heart_Rate__BPM_, 10)
);
maxLongevity = Math.max(
maxLongevity,
parseInt(Longevity__Years_, 10)
);
maxWeight = Math.max(maxWeight, parseInt(Mass__grams_, 10));
minWeight =
minWeight === 0
? parseInt(Mass__grams_, 10)
: Math.min(minWeight, parseInt(Mass__grams_, 10));
}
);
// ...
Nun zur Einrichtung der Skalierungsfunktionen! Hier werden wir die Funktionen scaleLinear und scaleLog von D3.js verwenden. Bei der Einrichtung legen wir die Domain fest, das ist der minimale und maximale Eingabewert, den die Funktionen erwarten können, und die Range, das ist der maximale und minimale Ausgabewert.
Wenn ich zum Beispiel self.heartScaleY mit dem Wert maxHeartrate aufrufe, wird die Ausgabe gleich marginTop sein. Das macht Sinn, da dies ganz oben im Diagramm sein wird. Für das Langlebigkeitsattribut benötigen wir zwei Skalierungsfunktionen, da diese Daten je nach gewählter Dropdown-Option entweder auf der x- oder der y-Achse erscheinen.
self.heartScaleY = scaleLinear()
.domain([maxHeartrate, minHeartrate])
.range([marginTop, chartHeight - marginY - marginTop]);
self.longevityScaleX = scaleLinear()
.domain([minLongevity, maxLongevity])
.range([paddingX + marginY, width - marginX - paddingX - paddingRight]);
self.longevityScaleY = scaleLinear()
.domain([maxLongevity, minLongevity])
.range([marginTop, chartHeight - marginY - marginTop]);
self.weightScaleX = scaleLog()
.base(2)
.domain([minWeight, maxWeight])
.range([paddingX + marginY, width - marginX - paddingX - paddingRight]);
Schließlich setzen wir self.ready auf true, da das Diagramm zum Rendern bereit ist.
Definieren der Ansichten
Wir haben zwei Sätze von Funktionen für die Ansichten. Der erste Satz gibt die Daten aus, die zum Rendern der Achsenticks benötigt werden (ich sagte, wir würden dorthin gelangen!), und der zweite Satz gibt die Daten aus, die zum Rendern der Punkte benötigt werden. Wir werden uns zuerst die Tick-Funktionen ansehen.
Es gibt nur zwei Tick-Funktionen, die von der React-App aufgerufen werden: getXAxis und getYAxis. Diese geben einfach die Ausgabe anderer View-Funktionen zurück, abhängig vom Wert von self.selectedAxes.
getXAxis() {
switch (self.selectedAxes) {
case 0:
return self.longevityXAxis;
break;
case 1:
case 2:
return self.weightXAxis;
break;
}
},
getYAxis() {
switch (self.selectedAxes) {
case 0:
case 1:
return self.heartYAxis;
break;
case 2:
return self.longevityYAxis;
break;
}
},
Wenn wir uns die Axis-Funktionen selbst ansehen, können wir sehen, dass sie eine Ticks-Methode der Skalierungsfunktion verwenden. Diese gibt ein Array von Zahlen zurück, das für eine Achse geeignet ist. Wir bilden dann über die Werte ab, um die Daten für unsere Achsenkomponente zurückzugeben.
heartYAxis() {
return self.heartScaleY.ticks(10).map(val => ({
label: val,
y: self.heartScaleY(val)
}));
}
// ...
Versuchen Sie, den Wert des Parameters für die Ticks-Funktion auf 5 zu ändern und sehen Sie, wie sich das auf das Diagramm auswirkt: self.heartScaleY.ticks(5).
Nun haben wir die View-Funktionen, um die Daten zurückzugeben, die für die Points-Komponente benötigt werden.
Wenn wir uns longevityHeartratePoints ansehen (welches die Punktdaten für das Diagramm "Langlebigkeit vs. Herzfrequenz" zurückgibt), sehen wir, dass wir über das Array von animals iterieren und die entsprechenden Skalierungsfunktionen verwenden, um die x- und y-Positionen für den Punkt zu erhalten. Für das pulse-Attribut verwenden wir einige mathematische Berechnungen, um den Wert der Herzfrequenz in Schlägen pro Minute in einen Wert umzuwandeln, der die Dauer eines einzelnen Herzschlags in Millisekunden darstellt.
longevityHeartratePoints() {
return self.animals.map(
({ Creature, Longevity__Years_, Resting_Heart_Rate__BPM_ }) => ({
y: self.heartScaleY(Resting_Heart_Rate__BPM_),
x: self.longevityScaleX(Longevity__Years_),
pulse: Math.round(1000 / (Resting_Heart_Rate__BPM_ / 60)),
label: Creature
})
);
},
Am Ende der Datei store.js müssen wir ein Store-Modell erstellen und es dann mit den Rohdaten der Tierobjekte instanziieren. Es ist ein gängiges Muster, alle Modelle an ein übergeordnetes Store-Modell anzuhängen, auf das bei Bedarf über einen Provider auf oberster Ebene zugegriffen werden kann.
const Store = types.model('Store', {
chartModel: ChartModel
});
const store = Store.create({
chartModel: { animals: data }
});
export default store;
Und das ist es! Hier ist unser Demo noch einmal
Dies ist keineswegs die einzige Möglichkeit, Daten zur Erstellung von Diagrammen in JSX zu organisieren, aber ich habe festgestellt, dass sie unglaublich effektiv ist. Ich habe diese Struktur und diesen Stack in der Praxis verwendet, um eine Bibliothek benutzerdefinierter Diagramme für einen großen Unternehmenskunden zu erstellen, und war überwältigt, wie gut MST für diesen Zweck funktionierte. Ich hoffe, Sie machen die gleiche Erfahrung!
Entschuldigen Sie meine Verwirrung, aber ist das nicht eine Mutation?
Hallo, Michel Weststrate erklärt das schön in seinem Artikel "The Curious Case of Mobx State Tree". Gehen Sie zu dem Abschnitt namens "A mutable immutable tree".
https://codeburst.io/the-curious-case-of-mobx-state-tree-7b4e22d461f
Ich verstehe, danke für den interessanten Link.
Toller Artikel übrigens! Sehr detailliert und umfassend.