Kapitel 2: Die erste Kurve
npm install ml-time-graphEine Zeitreihe ist eine Liste aus Zeitpunkten und Werten. Mehr braucht es nicht, um etwas zu sehen:
import { mount } from 'ml-time-graph';
const chart = mount('#chart', {
series: [
{
name: 'Temperature',
data: [
{ time: Date.parse('2026-08-31T00:00:00Z'), value: 6.2 },
{ time: Date.parse('2026-08-31T01:00:00Z'), value: 6.0 },
{ time: Date.parse('2026-08-31T02:00:00Z'), value: null },
],
},
],
});Bei drei Details liegst du leicht daneben, deshalb stehen sie hier
ausdrücklich: mount nimmt einen CSS-Selektor oder ein Element; time ist eine
Zahl in Millisekunden, kein Date; und value darf null sein — das ist
die Datenlücke aus dem übernächsten Abschnitt, kein Versehen.
Deine Daten sehen anders aus
Sie tun es fast immer. Eine Zeitreihendatenbank liefert Paare, über eine REST-API
heissen die Felder anders und evtl. ist die Zeitangabe in Sekunden, ein CSV-Auszug liefert alles als
Text. toDataPoints wandelt das um — an einer Stelle, statt in jedem Aufrufer:
import { toDataPoints, toSeries } from 'ml-time-graph';
toDataPoints([[1758434400000, 6.2], [1758438000000, 6.0]]);
toDataPoints(rows, { time: 'ts', value: 'val', timeUnit: 's' });
toDataPoints(rows, { time: (r) => r.meta.at, value: (r) => r.payload.kw });
// Name und Umwandlung in einer Zeile:
series: [toSeries('Temperature', rows, { time: 'ts', value: 'val' })];Warum die Mitte nicht einfach alles annimmt: { time, value } lesen die
Skalen, die Zonenteilung, der Tooltip und der Go-Port. Jede zusätzlich erlaubte
Form müsste durch jede dieser Stellen wandern — und in beiden Bibliotheken
gepflegt werden. Eng in der Mitte, weich am Rand.
Drei Entscheidungen darin sind absichtlich unbequem und sparen dir später einen Nachmittag:
- Zahlen sind Millisekunden, solange du nichts anderes sagst. Geraten wird
nicht: Eine relative Zeitbasis (0, 1000, 2000 …) würde dabei stillschweigend
um den Faktor 1000 verschoben. Für Unix-Sekunden gibt es
timeUnit: 's'— und'auto', das genau diese relative Basis in Ruhe lässt. - Ein fehlender Wert wird zur Lücke, nicht zur Null.
null,undefined,NaNund der leere Text brechen die Linie. Eine fehlende Messung als 0 zu zeichnen hieße, einen Wert zu behaupten, den der Sensor nie gemeldet hat. - Text, der keine Zahl ist, wirft — mit der Zeilennummer. Das ist fast immer ein vertippter Feldname, und ein leeres Diagramm sagt dir das nicht.
ISO-Zeichenketten und Date-Objekte erkennt die Umwandlung von selbst,
Zahlen in Textform ('21.5') rechnet sie um, und sortiert wird nach Zeit.
Was die Bibliothek von sich aus tut
Achsen skalieren, Zeit-Ticks auswählen, Beschriftungen platzieren, ohne dass jemand Zahlen vorgibt. Das klingt selbstverständlich und ist es nicht: An der Tick-Auswahl scheiden sich Chart-Bibliotheken.
Über 24 Stunden willst du Stunden, über zwei Jahre Monate — und dazwischen etwas, das weder überladen noch kahl ist. Die Bibliothek entscheidet das anhand des tatsächlichen Bereichs, nicht nach einer starren Regel.
Große Werte werden nur dann zu 1.5k oder 2.0M verkürzt, wenn die
Schrittweite es erlaubt — eine Druckachse um 1000 hPa mit Ticks alle 10 behält
1000, 1010, 1020, denn verkürzt hießen sie alle 1.0k. Gitterlinien sind
ein Schalter je Achse:
axes: { x: { grid: true }, left: { grid: true } }Luft am Rand
Die berechnete Achse endet genau auf dem kleinsten und größten Messwert. Das ist richtig — und hat einen sichtbaren Nebeneffekt: Die Zeichenfläche ist auf ihr Rechteck beschnitten, also liegt bei einer breiten Linie am Maximum die halbe Strichbreite außerhalb. Bei zehn Pixeln fehlen fünf, und ein Marker verliert die halbe Scheibe.
padding behält die automatische Berechnung und macht Platz darum herum:
axes: { left: { padding: 8 } } // oben und unten
axes: { left: { padding: { top: 24, bottom: 4 } } } // etwa für ein Label obenAngegeben wird in Pixeln, nicht als Anteil des Wertebereichs. Der Grund liegt in der Ursache: Die stammt aus Strichbreite und Markergröße, und die sind in Pixeln gemessen. Dieselbe Acht passt damit für eine Messreihe in °C wie für eine in Kilowatt — ein Prozentsatz müsste für jedes Diagramm neu gesucht werden. Als Faustregel: die halbe Strichbreite plus etwas.
Zwei Dinge macht padding bewusst nicht. Es rührt eine ausdrücklich gesetzte
domain(y-Bereich) nicht an — wer den Bereich selbst bestimmt, will ihn nicht
stillschweigend erweitert bekommen. Und eine Polsterung, die nicht in die Höhe
des Diagramms passt, wird übergangen statt eine umgedrehte Skala zu ergeben:
Eine Kurve am Rand ist immer noch besser als keine.
Punkte auf der Kurve
Marker zeigen, wo wirklich gemessen wurde — bei einer geglätteten Linie ist das sonst nicht zu sehen. Sie haben ihren eigenen Stift und ihre eigenen Farben und erben die Strichbreite der Linie nicht:
style: {
line: { color: '#4285f4', width: 6 },
markers: {
type: 'circle', // 13 Formen
size: 5, // Radius in Pixeln
stroke: '#1e3a8a', // Rand; ohne Angabe die Linien- bzw. Zonenfarbe
fill: '#ffffff', // Füllung; ohne Angabe ebenso
strokeWidth: 1.5, // der eigene Stift, unabhängig von line.width
threshold: 500, // nur zeichnen, solange die Reihe ≤ n Punkte hat
},
}Die letzte Zeile ist die wichtigste bei langen Reihen: Über ein paar hundert
Punkten verschmelzen die Symbole zu einem Band und sagen nichts mehr. threshold
blendet sie ab dieser Menge aus, und die Linie bleibt.
Im Style-Editor der Demo-Galerie stehen Line width und Marker pen als zwei getrennte Regler nebeneinander — dort sieht man den Unterschied in einer Sekunde.
Eine Lücke ist eine Lücke
Fehlt ein Wert, bricht die Linie ab — sie wird nicht interpoliert. Eine durchgezogene Linie über eine Datenlücke hinweg behauptet Daten, die es nicht gibt, und genau da werden falsche Schlüsse gezogen.
Ein Gerät, das verstummt ist
Eine Linie, die einfach aufhört, sagt nichts — und in der Überwachung ist ein
Fühler, der nicht mehr meldet, die wichtigste Nachricht auf dem Bildschirm.
gaps.atEnd markiert ihn:
gaps: { atEnd: { afterMs: 30 * 60_000, label: 'keine Daten seit {since} · {duration}' } }Liegt der letzte Messwert mehr als afterMs vor jetzt, läuft eine schraffierte
Fläche von diesem Wert bis jetzt, beschriftet mit {since} (mit Datum, wenn es
ein anderer Tag war) und {duration} (45 min, 3 h 12 min). Die Bibliothek
liest keine Uhr: jetzt ist atEnd.now oder das Ende der Zeitachse, Bildschirm,
PDF und Test zeigen also dasselbe. Eine Reihe kann aussteigen —
gaps: { atEnd: false } bei einem Fühler, der nur bei Änderung meldet — oder ihr
eigenes afterMs setzen.
Ein Kontakt, der nur bei Änderung meldet
Der umgekehrte Fall: Ein Türkontakt schweigt mit Absicht. Er meldet, wenn die Tür aufgeht
und wenn sie zugeht, dazwischen nichts, und „zu" gilt bis zur nächsten Meldung. Seine Linie
endet aber bei dieser letzten Meldung und sieht genauso aus wie der Ausfall oben.
holdToEnd sagt, was die Daten meinen:
{ name: 'Tür', data, holdToEnd: true } // bis zum Ende der Achse
{ name: 'Fühler', data, holdToEnd: { maxAgeMs: 30 * 60_000 } } // höchstens 30 MinutenLinie und Fläche laufen bis zum Ende der Zeitachse weiter, oder bis gaps.atEnd.now; mit
maxAgeMs höchstens so lange nach der letzten Meldung. Verlängert wird nur die Zeichnung.
Punktmarker, Tooltip, Statistik und Wertachse sehen weiter die echten Messwerte, und die Reihe
bekommt keine gaps.atEnd-Fläche, denn sie ist per Definition nicht verstummt. Ein
value: null am Ende beendet die Linie weiterhin, denn diese Lücke steht in den Daten.
Stufen, keine Kurven
Nicht jede Größe ändert sich stetig. Ein Schaltzustand, ein Zähler, ein Sollwert springt — und gehört als Stufe gezeichnet, nicht als Kurve. Eine schräge Linie zwischen zwei Schaltzuständen zeigt einen Übergang, den es nie gegeben hat.
Eine glatte Kurve darf keine Messwerte erfinden
Glätten ist eine Aussage, keine Verzierung. Zwischen zwei Messwerten muss die Bibliothek irgendetwas zeichnen, und das Verfahren entscheidet, was dieses Irgendetwas behauptet.
Die Vorgabe, eine Catmull-Rom-Spline, nimmt ihre Richtung aus den Nachbarpunkten. Das ist weich und billig — und es schwingt über: zwischen zwei Messwerten entstehen Werte, die keiner von beiden ist. Hat die Größe einen Anschlag, behauptet das Bild damit einen Messwert, den es nicht geben kann: Feuchtigkeit über 100 %, ein Tank voller als voll.
Mit smoothingType: 'monotone' bleibt die Kurve zwischen ihren beiden
Messwerten. Ein Plateau bleibt ein Plateau.
Der zweite Fall ist die Kante. Ein Sensor, der nur bei einer Änderung funkt,
liefert eine lange ruhige Strecke und dann einen Sturz — ein Fenster geht auf,
eine Pumpe schaltet ab. Catmull-Rom sieht den Sturz kommen und setzt sich früh in
Bewegung: es steigt, bevor es fällt, das Diagramm zeigt also eine kurze Wärme,
die es nie gab, und unterschießt danach. smoothingType: 'akima' gewichtet die
Steigung gegen diese Kante und hält die flache Linie, bis der Sturz da ist.
Beide laufen im Browser und auf dem Server und ergeben dieselbe Kurve. Und beide
tun nur dort etwas, wo smoothing an ist: das Verfahren wählt, wie geglättet
wird, nie ob. Die Messwerte einzublenden — markers: { type: 'circle' }, wie
in den Abbildungen oben — lohnt sich überall, wo geglättet wird. Nur daran sieht
ein Leser, welche Punkte gemessen und welche von der Kurve erfunden sind.