MLTimeGraph

Kapitel 4: Was nur der Browser kann

Die vorigen Kapitel ließen sich mit Bildern erzählen. Dieses hier nicht — es geht um Dinge, die du tun musst, um sie zu verstehen.

Zoom und Pan

Ein Bereich, den du selbst einstellen kannst, ist grundlegend etwas anderes als einer, den jemand für dich ausgewählt hat. Ziehen zum Hereinzoomen, Doppelklick zum Zurücksetzen, Mausrad für stufenlosen Zoom.

Zoom und Zeitbereich
Zoom und Zeitbereich — lebende Demo öffnen

Viele Punkte

30.000 Messwerte sind kein Randfall, sondern ein Jahr in Minutenauflösung. Die Bibliothek zeichnet nicht jeden Punkt, sondern das, was bei der aktuellen Auflösung sichtbar wäre — und zwar so, dass Ausreißer erhalten bleiben. Naives Ausdünnen würde genau die Spitzen verschlucken, nach denen du suchst.

Langzeittrend über zwei Jahre, 30.000 Punkte
Langzeittrend über zwei Jahre, 30.000 Punkte — lebende Demo öffnen

Mehrere Achsen

Temperatur und Luftfeuchtigkeit in einem Bild, auf getrennten Skalen. Die zweite Achse ist praktisch und verführerisch: Zwei Kurven in einem Rahmen suggerieren einen Zusammenhang, den die Wahl der Skalen erzeugt hat, nicht die Daten. Nutze sie dort, wo der Zusammenhang feststeht, nicht um einen zu unterstellen.

Getrennte Skalen in einem Diagramm
Getrennte Skalen in einem Diagramm — lebende Demo öffnen

Ab drei Achsen — oder wenn du selbst bestimmen willst, welche links und welche rechts steht — nimmst du statt axes.left und axes.right die Array-Form axes.y und ordnest jede Reihe über yAxisIndex zu. Jede Achse trägt dann ihren eigenen Titel, in ihrer eigenen Farbe, neben ihrer eigenen Linie:

axes: {
  y: [
    { position: 'left',  label: 'Temperatur (°C)', axis: { color: '#ef4444' } },
    { position: 'left',  label: 'Taupunkt (°C)',   axis: { color: '#f59e0b' } },
    { position: 'right', label: 'Feuchte (%)',     axis: { color: '#3b82f6' } },
    { position: 'right', label: 'Druck (hPa)',     axis: { color: '#10b981' } },
  ],
},
series: [{ name: 'Temperatur', data, yAxisIndex: 0 }, /* … */],
Vier Werteachsen über axes.y, je zwei pro Seite — vom Go-Renderer gezeichnet.

Wo der Titel steht, sucht sich die Bibliothek selbst: neben den Tick-Beschriftungen seiner Achse, und zwar in der ersten Lücke, die breit genug ist. Was nicht mehr hineinpasst, rückt nach außen an den Rand. Damit ein Titel zwischen zwei Achsen Platz findet, braucht er rund 14 px zusätzlich zu den Beschriftungen — bei Werten wie 1030 hPa also etwa 70 px Achsenabstand statt der voreingestellten 50:

{ position: 'left', offset: 75, label: 'Taupunkt (°C)' }

Ohne den Platz stehen die Titel beieinander am Rand statt je an ihrer Achse. Übereinander stehen sie nie: jeder belegte Streifen ist für den nächsten Titel gesperrt.

Soll ein Titel an einer bestimmten Stelle stehen, setzt labelOffset sie: den Abstand von der Achsenlinie bis zur Mitte des Titels. Für diesen Titel entfällt die Suche, die anderen weichen ihm aus. Das gilt auch in der Form mit left/right, wo der Titel sonst 14 px vom Bildrand steht:

{ position: 'left', label: 'Taupunkt (°C)', labelOffset: 34 }
Zwei Titel mit festem Abstand (34 px links, 60 px rechts ihrer Achse); der Temperatur-Titel hat seine Spur zwischen ihnen gefunden.

Welche Reihen sich eine Achse teilen, kann man die Daten fragen. axisGroups nimmt je Reihe Einheit und Messwerte und schlägt vor: gleiche Einheit und benachbarte Bereiche teilen sich eine; eine Lücke größer als der größere Bereich bekommt eine eigene. Die Reihenfolge bleibt, die Achsen springen also nicht, wenn ein Fühler dazukommt:

import { axisGroups } from 'ml-time-graph';

const { indices, axes } = axisGroups(fuehler.map((f) => ({ unit: f.unit, data: f.data })));
new MLTimeGraph({
  series: fuehler.map((f, i) => ({ ...f, yAxisIndex: indices[i] })),
  axes: { y: axes },
});

Angewendet wird nichts — es ist ein Vorschlag, den du noch überstimmen kannst.

Eine Legende zum Anklicken

Bei fünf Messreihen in einem Bild ist das Ausblenden von vieren der schnellste Weg, die fünfte anzusehen. Bis 1.12.0 hieß das: die Legende selbst zeichnen. Die eingebaute war eine Beschriftung und sonst nichts — auf einen Klick passierte nichts, und an einen einzelnen Eintrag kam man gar nicht heran.

Jetzt steht jeder Eintrag in einer eigenen Gruppe und trägt denselben Schlüssel wie die Serie, die er benennt: #legend-temperature hier, #series-temperature dort. Von einem zum anderen führt ein Ersetzen, kein Nachschlagen über den Klarnamen. itemClass hängt eine eigene Klasse an, und mehr bietet die Bibliothek nicht an — sie bringt nichts zum Klicken mit, sie bringt etwas zum Klicken hin:

legend: { show: true, itemClass: 'clickmetotoggle' }
container.addEventListener('click', (e) => {
  const item = e.target.closest('.clickmetotoggle');
  if (!item) return;
  container.querySelector('#' + item.id.replace('legend-', 'series-'))
    .classList.toggle('hidden');       // .hidden { display: none }
});

Ein Handler für das ganze Diagramm. Jede Demo auf dieser Seite benutzt genau diesen Code, einmal in der Demo-Maschinerie eingehängt — probier es an einem beliebigen Diagramm mit Legende weiter unten aus.

Dass das überhaupt trägt, hängt an einer Zusage aus 1.3.0: der Schlüssel ist stabil. Er kommt aus der id der Serie, sonst aus ihrem Namen, und er ist in TypeScript und Go derselbe. Vorher trug er einen Zeitstempel und änderte sich bei jedem Rendern — ein Stilblatt oder ein Handler dagegen zeigte eine Sekunde später ins Leere.

Zwei Diagramme auf derselben Seite, die beide eine Reihe Temperatur zeigen, schrieben #series-temperature zweimal — ungültiges HTML, und getElementById findet das falsche Diagramm. Seit 1.17 nimmt jedes Diagramm einen idPrefix:

new MLTimeGraph({ idPrefix: 'keller', /* … */ });   // #keller-series-temperature

Der Handler oben läuft unverändert weiter: aus keller-legend-temperature wird keller-series-temperature. Die Klassen .legend-item--temperature und .series--temperature tragen keinen Präfix — sie sind für Stilblätter, und die sollen alle Diagramme zugleich treffen.

Das umgekehrte Problem kommt aus der Seite um das Diagramm herum. Die Klassen sind schlichte Wörter, series, time-axis, chart-grid, und eine Regel wie .series { display: none } im Stilblatt der Seite träfe sie. classPrefix setzt einen Präfix vor jede Klasse, die das Diagramm schreibt. Anders als idPrefix ist er für alle Diagramme einer Anwendung derselbe:

new MLTimeGraph({ classPrefix: 'mltg', /* … */ });   // class="mltg-series mltg-series--temperature"

Die eigene legend.itemClass bleibt, wie du sie geschrieben hast, denn dein Handler hängt daran.

Selbst zeichnen

Mit position: 'separate' zeichnet die Bibliothek keine — chart.legendItems() gibt dir, was sie gezeichnet hätte, und du entscheidest, wo sie steht und was sie tut. Das bleibt der Weg, wenn die Legende als HTML unter das Diagramm gehört, in eine Seitenleiste, oder wenn sie mehr können muss als einen Klick.

Jeder Eintrag trägt mehr als Name und Farbe — die Linienart seiner Reihe und, wenn das Diagramm sie zeichnet, ihr Symbol. Das zählt, sobald das Bild den Bildschirm verlässt. Auf Papier ist jedes farbige Kästchen dasselbe Grau, und wem Rot und Grün gleich aussehen, dem hat die Farbe ohnehin nie geholfen; Strichmuster und Symbol überstehen beides. Zeichne das Kästchen mit drawMarker und dem Renderer, dann kann es nicht von der Kurve abweichen, die es benennt.

Ein Symbol wird nur gemeldet, wenn es auch gezeichnet wird: Eine Reihe, die ihre Marker oberhalb einiger hundert Punkte ausblendet, bekommt auch in der Legende keines. Ein Symbol, das im Bild fehlt, behauptet etwas Falsches — und auf Papier kann das niemand prüfen.

Legendenplatzierung
Legendenplatzierung — lebende Demo öffnen

Ein eigener Tooltip

Der eingebaute Tooltip ist HTML, das die Bibliothek schreibt. Sobald er aussehen soll wie der Rest deiner Anwendung, willst du stattdessen die Daten und schreibst das HTML selbst. Drei Teile reichen dafür:

import { MLTimeGraph, autoMargin, zeigerImSvg } from 'ml-time-graph';

const chart = new MLTimeGraph({ ...optionen, margin: autoMargin(optionen) });

svg.addEventListener('pointermove', (e) => {
  const { x, y } = zeigerImSvg(svg, e.clientX, e.clientY);  // bei jeder Skalierung
  const p = chart.plotRect;                                  // die Zeichenfläche
  if (x < p.x || x > p.x + p.width) return verbergen();
  zeigen(chart.pointsAt(x, y, { overlays: true }));          // eine Zeile je Reihe
});

pointsAt liefert den Wert, den die gezeichnete Linie am Zeiger zeigt — in fester Reihenfolge, nicht nach Abstand sortiert. Eine Treppe hält ihren letzten Wert; ein Fühler, der nur bei Änderung meldet, bekommt seinen letzten Messwert, markiert mit held und eigener time; eine Enum-Reihe bringt neben der Zahl ihr label ("offen") mit. plotRect ist die Fläche innerhalb der Ränder, ein Fadenkreuz braucht also keine Randrechnung, und autoMargin errechnet die Ränder aus dem, was wirklich gezeichnet wird — Achsenbeschriftungen, Titel, Grenzwert-Beschriftungen außerhalb.

Eigener Tooltip aus pointsAt: Treppe, ein Fühler, der nur bei Änderung meldet, gleitendes Mittel
Eigener Tooltip aus pointsAt: Treppe, ein Fühler, der nur bei Änderung meldet, gleitendes Mittel — lebende Demo öffnen

Ein ganzes Dashboard

Mehrere Diagramme, ein gemeinsamer Zeitbereich, synchronisierter Zoom.

Mehrere Diagramme mit gemeinsamem Zeitbereich
Mehrere Diagramme mit gemeinsamem Zeitbereich — lebende Demo öffnen

Vom selben Autor

Lizenz: CC BY-NC 4.0