Wie man ein animiertes Diagramm aus verschachtelten Quadraten mit Masken erstellt

Avatar of Mészáros Róbert
Mészáros Róbert am

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

Wir haben viele bekannte Diagrammtypen: Balken-, Donut-, Linien-, Tortendiagramme, und so weiter. Alle beliebten Diagrammbibliotheken unterstützen diese. Dann gibt es noch die Diagrammtypen, die nicht einmal einen Namen haben. Schauen Sie sich dieses ausgedachte Diagramm mit gestapelten (verschachtelten) Quadraten an, das helfen kann, relative Größen zu visualisieren oder wie verschiedene Werte miteinander verglichen werden.

Was wir machen

Ohne jegliche Interaktivität ist die Erstellung dieses Designs ziemlich einfach. Ein Weg, dies zu tun, ist das Stapeln von Elementen (z. B. SVG <rect>-Elementen oder sogar HTML-Divs) in abnehmenden Größen, wobei sich alle ihre linken unteren Ecken am selben Punkt berühren.

Aber die Dinge werden kniffliger, sobald wir einige Interaktivität einführen. So soll es sein: Wenn wir mit der Maus über eine der Formen fahren, möchten wir, dass die anderen verblassen und sich wegbewegen.

Wir werden diese unregelmäßigen Formen mit Rechtecken und Masken erstellen – buchstäblich <svg> mit <rect>- und <mask>-Elementen. Wenn Sie neu im Bereich Masken sind, sind Sie hier genau richtig. Dies ist ein Artikel für Anfänger. Wenn Sie erfahrener sind, dann ist dieser Ausschnitteffekt vielleicht ein Trick, den Sie mitnehmen können.

Nun, bevor wir beginnen, fragen Sie sich vielleicht, ob es eine bessere Alternative zu SVG gibt, um benutzerdefinierte Formen zu verwenden. Das ist definitiv eine Möglichkeit! Aber das Zeichnen von Formen mit einem <path> kann einschüchternd sein oder sogar unordentlich werden. Wir arbeiten also mit „einfacheren“ Elementen, um dieselben Formen und Effekte zu erzielen.

Hier ist zum Beispiel, wie wir die größte blaue Form mit einem <path> darstellen müssten.

<svg viewBox="0 0 320 320" width="320" height="320">
  <path d="M320 0H0V56H264V320H320V0Z" fill="#264653"/>
</svg>

Wenn das 0H0V56… für Sie keinen Sinn ergibt, lesen Sie „Die SVG-path-Syntax: Ein illustrierter Leitfaden“ für eine gründliche Erklärung der Syntax.

Die Grundlagen des Diagramms

Gegeben ein Datensatz wie dieser

type DataSetEntry = {
  label: string;
  value: number;
};

type DataSet = DataSetEntry[];

const rawDataSet: DataSet = [
  { label: 'Bad', value: 1231 },
  { label: 'Beginning', value: 6321 },
  { label: 'Developing', value: 10028 },
  { label: 'Accomplished', value: 12123 },
  { label: 'Exemplary', value: 2120 }
];

…wollen wir mit einem SVG wie diesem enden

<svg viewBox="0 0 320 320" width="320" height="320">
  <rect width="320" height="320" y="0" fill="..."></rect>
  <rect width="264" height="264" y="56" fill="..."></rect>
  <rect width="167" height="167" y="153" fill="..."></rect>
  <rect width="56" height="56" y="264" fill="..."></rect>
  <rect width="32" height="32" y="288" fill="..."></rect>
</svg>

Bestimmung des höchsten Werts

Es wird gleich ersichtlich, warum wir den höchsten Wert benötigen. Wir können Math.max() verwenden, um ihn zu erhalten. Er akzeptiert beliebig viele Argumente und gibt den höchsten Wert in einer Menge zurück.

const dataSetHighestValue: number = Math.max(
  ...rawDataSet.map((entry: DataSetEntry) => entry.value)
);

Da wir einen kleinen Datensatz haben, können wir einfach sagen, dass wir 12123 erhalten.

Berechnung der Abmessungen der Rechtecke

Wenn wir uns das Design ansehen, deckt das Rechteck, das den höchsten Wert (12123) repräsentiert, den gesamten Bereich des Diagramms ab.

Wir haben willkürlich 320 für die SVG-Abmessungen gewählt. Da unsere Rechtecke Quadrate sind, sind Breite und Höhe gleich. Wie können wir 12123 gleich 320 setzen? Und wie ist es mit den weniger „besonderen“ Werten? Wie groß ist das 6321-Rechteck?

Anders ausgedrückt, wie bilden wir eine Zahl von einem Bereich ([0, 12123]) auf einen anderen ab ([0, 320])? Oder, in mathematischeren Begriffen, wie skalieren wir eine Variable auf ein Intervall von [a, b]?

Für unsere Zwecke werden wir die Funktion wie folgt implementieren

const remapValue = (
  value: number,
  fromMin: number,
  fromMax: number,
  toMin: number,
  toMax: number
): number => {
  return ((value - fromMin) / (fromMax - fromMin)) * (toMax - toMin) + toMin;
};

remapValue(1231, 0, 12123, 0, 320); // 32
remapValue(6321, 0, 12123, 0, 320); // 167
remapValue(12123, 0, 12123, 0, 320); // 320

Da wir Werte auf denselben Bereich in unserem Code abbilden, können wir anstatt die Minima und Maxima immer wieder zu übergeben, eine Wrapper-Funktion erstellen

const valueRemapper = (
  fromMin: number,
  fromMax: number,
  toMin: number,
  toMax: number
) => {
  return (value: number): number => {
    return remapValue(value, fromMin, fromMax, toMin, toMax);
  };
};

const remapDataSetValueToSvgDimension = valueRemapper(
  0,
  dataSetHighestValue,
  0,
  svgDimension
);

Wir können sie so verwenden

remapDataSetValueToSvgDimension(1231); // 32
remapDataSetValueToSvgDimension(6321); // 167
remapDataSetValueToSvgDimension(12123); // 320

Erstellung und Einfügung der DOM-Elemente

Was übrig bleibt, hat mit DOM-Manipulation zu tun. Wir müssen das <svg> und die fünf <rect>-Elemente erstellen, ihre Attribute setzen und sie an den DOM anhängen. Dies können wir alles mit den grundlegenden Funktionen createElementNS, setAttribute und appendChild tun.

Beachten Sie, dass wir createElementNS anstelle des gebräuchlicheren createElement verwenden. Das liegt daran, dass wir mit SVG arbeiten. HTML- und SVG-Elemente haben unterschiedliche Spezifikationen und fallen daher unter eine andere Namespace-URI. Es ist zufällig so, dass createElement praktisch den HTML-Namespace verwendet! Um also ein SVG zu erstellen, müssen wir so ausführlich sein

document.createElementNS('http://www.w3.org/2000/svg', 'svg') as SVGSVGElement;

Sicherlich können wir eine weitere Hilfsfunktion erstellen

const createSvgNSElement = (element: string): SVGElement => {
  return document.createElementNS('http://www.w3.org/2000/svg', element);
};

Beim Anhängen der Rechtecke an den DOM müssen wir auf ihre Reihenfolge achten. Andernfalls müssten wir den z-index explizit angeben. Das erste Rechteck muss das größte sein, und das letzte Rechteck muss das kleinste sein. Am besten sortieren Sie die Daten vor der Schleife.

const data = rawDataSet.sort(
  (a: DataSetEntry, b: DataSetEntry) => b.value - a.value
);

data.forEach((d: DataSetEntry, index: number) => {
  const rect: SVGRectElement = createSvgNSElement('rect') as SVGRectElement;
  const rectDimension: number = remapDataSetValueToSvgDimension(d.value);

  rect.setAttribute('width', `${rectDimension}`);
  rect.setAttribute('height', `${rectDimension}`);
  rect.setAttribute('y', `${svgDimension - rectDimension}`);

  svg.appendChild(rect);
});

Das Koordinatensystem beginnt oben links; dort ist [0, 0]. Wir werden die Rechtecke immer von der linken Seite aus zeichnen. Das x-Attribut, das die horizontale Position steuert, hat standardmäßig 0, daher müssen wir es nicht setzen. Das y-Attribut steuert die vertikale Position.

Um den visuellen Eindruck zu erwecken, dass alle Rechtecke von demselben Punkt ausgehen, der ihre linken unteren Ecken berührt, müssen wir die Rechtecke sozusagen nach unten verschieben. Um wie viel? Genau um den Betrag, den das Rechteck nicht ausfüllt. Und dieser Wert ist die Differenz zwischen der Abmessung des Diagramms und dem jeweiligen Rechteck. Wenn wir alle Teile zusammenfügen, erhalten wir dies

Wir haben den Code für die Animation bereits mit CSS in dieser Demo hinzugefügt.

Ausschnitt-Rechtecke

Wir müssen unsere Rechtecke in unregelmäßige Formen verwandeln, die ein wenig wie die Zahl Sieben aussehen, oder wie der Buchstabe L um 180 Grad gedreht.

Wenn wir uns auf die „fehlenden Teile“ konzentrieren, sehen wir, dass sie Ausschnitte derselben Rechtecke sind, mit denen wir bereits arbeiten.

Wir möchten diese Ausschnitte ausblenden. So werden wir am Ende die L-Formen erhalten, die wir wollen.

Maskierung 101

Eine Maske ist etwas, das man definiert und später auf ein Element anwendet. Typischerweise wird die mask inline in das <svg>-Element eingebettet, zu dem sie gehört. Und im Allgemeinen sollte sie eine eindeutige id haben, da wir auf sie verweisen müssen, um die Maske auf ein Element anzuwenden.

<svg>
  <mask id="...">
    <!-- ... -->
  </mask>
</svg>

Im <mask>-Tag platzieren wir die Formen, die als eigentliche Masken dienen. Wir wenden auch das mask-Attribut auf die Elemente an.

<svg>
  <mask id="myCleverlyNamedMask">
    <!-- ... -->
  </mask>
  <rect mask="url(#myCleverlyNamedMask)"></rect>
</svg>

Das ist nicht die einzige Methode, eine Maske zu definieren oder anzuwenden, aber es ist die direkteste für diese Demo. Lassen Sie uns ein wenig experimentieren, bevor wir Code zum Generieren der Masken schreiben.

Wir sagten, dass wir die ausgeschnittenen Bereiche abdecken wollen, die den Größen der vorhandenen Rechtecke entsprechen. Wenn wir das größte Element nehmen und das vorherige Rechteck als Maske anwenden, erhalten wir diesen Code

<svg viewBox="0 0 320 320" width="320" height="320">
  <mask id="theMask">
    <rect width="264" height="264" y="56" fill=""></rect>
  </mask>
  <rect width="320" height="320" y="0" fill="#264653" mask="url(#theMask)"></rect>
</svg>

Das Element innerhalb der Maske benötigt einen fill-Wert. Was sollte das sein? Wir werden völlig unterschiedliche Ergebnisse sehen, je nachdem, welche fill-Farbe wir wählen.

Der weiße Füllwert

Wenn wir white als fill-Wert verwenden, erhalten wir dies

Nun hat unser großes Rechteck dieselbe Abmessung wie das Maskierungsrechteck. Nicht ganz das, was wir wollten.

Der schwarze Füllwert

Wenn wir stattdessen black als Wert verwenden, sieht es so aus

Wir sehen nichts. Das liegt daran, dass das, was mit Schwarz gefüllt ist, unsichtbar wird. Wir steuern die Sichtbarkeit von Masken mit white- und black-Füllungen. Die gestrichelten Linien sind als visuelle Hilfe zur Referenzierung der Abmessungen des unsichtbaren Bereichs vorhanden.

Der graue Füllwert

Lassen Sie uns nun etwas zwischen Weiß und Schwarz verwenden, sagen wir gray

Es ist weder vollständig opak noch solide; es ist transparent. Nun wissen wir also, dass wir den „Grad der Sichtbarkeit“ hier steuern können, indem wir etwas anderes als white- und black-Werte verwenden, was ein guter Trick ist, den wir uns merken sollten.

Das letzte Stück

Hier ist, was wir bisher über Masken behandelt und gelernt haben

  • Das Element innerhalb der <mask> steuert die Abmessungen des maskierten Bereichs.
  • Wir können den Inhalt des maskierten Bereichs sichtbar, unsichtbar oder transparent machen.

Wir haben nur eine Form für die Maske verwendet, aber wie bei jedem Allzweck-HTML-Tag können wir beliebig viele Kindelemente darin verschachteln. Tatsächlich besteht der Trick, um das zu erreichen, was wir wollen, darin, zwei SVG <rect>-Elemente zu verwenden. Wir müssen sie übereinander stapeln

<svg viewBox="0 0 320 320" width="320" height="320">
  <mask id="maskW320">
    <rect width="320" height="320" y="0" fill="???"></rect>
    <rect width="264" height="264" y="56" fill="???"></rect>
  </mask>
  <rect width="320" height="320" y="0" fill="#264653" mask="url(#maskW320)"></rect>
</svg>

Eines unserer Maskierungsrechtecke ist white gefüllt; das andere ist black gefüllt. Auch wenn wir die Regeln kennen, probieren wir die Möglichkeiten aus.

<mask id="maskW320">
  <rect width="320" height="320" y="0" fill="black"></rect>
  <rect width="264" height="264" y="56" fill="white"></rect>
</mask>

Die <mask> hat die Abmessungen des größten Elements und das größte Element ist schwarz gefüllt. Das bedeutet, dass alles unter diesem Bereich unsichtbar ist. Und alles unter dem kleineren Rechteck ist sichtbar.

Nun drehen wir die Dinge um, sodass das schwarze Rechteck oben liegt

<mask id="maskW320">
  <rect width="320" height="320" y="0" fill="white"></rect>
  <rect width="264" height="264" y="56" fill="black"></rect>
</mask>

Das ist es, was wir wollen!

Alles unter dem größten weiß gefüllten Rechteck ist sichtbar, aber das kleinere schwarze Rechteck liegt darüber (näher an uns in der z-Achse) und maskiert diesen Teil.

Generierung der Masken

Jetzt, da wir wissen, was zu tun ist, können wir die Masken relativ einfach erstellen. Es ist ähnlich wie wir ursprünglich die gefärbten Rechtecke generiert haben – wir erstellen eine zweite Schleife, in der wir die mask und die beiden rects erstellen.

Dieses Mal fügen wir die rects nicht direkt zum SVG hinzu, sondern zur mask

data.forEach((d: DataSetEntry, index: number) => {
  const mask: SVGMaskElement = createSvgNSElement('mask') as SVGMaskElement;

  const rectDimension: number = remapDataSetValueToSvgDimension(d.value);
  const rect: SVGRectElement = createSvgNSElement('rect') as SVGRectElement;

  rect.setAttribute('width', `${rectDimension}`);
  // ...setting the rest of the attributes...

  mask.setAttribute('id', `maskW${rectDimension.toFixed()}`);

  mask.appendChild(rect);

  // ...creating and setting the attributes for the smaller rectangle...

  svg.appendChild(mask);
});

data.forEach((d: DataSetEntry, index: number) => {
    // ...our code to generate the colored rectangles...
});

Wir könnten den Index als ID der Maske verwenden, aber dies scheint eine besser lesbare Option zu sein, zumindest für mich

mask.setAttribute('id', `maskW${rectDimension.toFixed()}`); // maskW320, masW240, ...

Um das kleinere Rechteck in der Maske hinzuzufügen, haben wir einfachen Zugriff auf den benötigten Wert, da wir die Rechteckwerte zuvor von höchstem zu niedrigstem geordnet haben. Das bedeutet, dass das nächste Element in der Schleife das kleinere Rechteck ist, auf das wir verweisen sollten. Und das können wir über seinen Index tun.

// ...previous part where we created the mask and the rectangle...

const smallerRectIndex = index + 1;

// there's no next one when we are on the smallest
if (data[smallerRectIndex] !== undefined) {
  const smallerRectDimension: number = remapDataSetValueToSvgDimension(
    data[smallerRectIndex].value
  );
  const smallerRect: SVGRectElement = createSvgNSElement(
    'rect'
  ) as SVGRectElement;

  // ...setting the rectangle attributes...

  mask.appendChild(smallerRect);
}

svg.appendChild(mask);

Was übrig bleibt, ist das Hinzufügen des mask-Attributs zum gefärbten Rechteck in unserer ursprünglichen Schleife. Es sollte dem von uns gewählten Format entsprechen

rect.setAttribute('mask', `url(#maskW${rectDimension.toFixed()})`); // maskW320, maskW240, ...

Das Endergebnis

Und damit sind wir fertig! Wir haben erfolgreich ein Diagramm aus verschachtelten Quadraten erstellt. Es zerfällt sogar beim Überfahren mit der Maus. Und alles, was es brauchte, war etwas SVG mit dem <mask>-Element, um den Ausschnittbereich jedes Quadrats zu zeichnen.