MLTimeGraph

Kapitel 2: Die erste Kurve

npm install ml-time-graph

Eine 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, NaN und 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 } }
Mehrere Messreihen mit unterschiedlichen Wertebereichen in einem Diagramm.

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 oben

Angegeben 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.

Eine Messreihe mit Ausfällen. Der Abbruch ist die Aussage.

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 verstummter Fühler, markiert bis zum Ende der Achse
Ein verstummter Fühler, markiert bis zum Ende der Achse — lebende Demo öffnen

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 Minuten

Linie 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.

Eine Tür, die „zu" bis zum Ende der Achse hält, ein Fühler, der 30 Minuten nach seinem letzten Wert gehalten wird, und einer ohne holdToEnd, der seinen Ausfall am Ende behält.

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.

Treppendiagramm für Größen, die springen statt gleiten.

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.

Die Kreise sind die Messwerte. Catmull-Rom (gestrichelt) wölbt sich über die Sättigungslinie, die monotone Spline liegt darauf.

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.

Vor dem Sturz steigt die gestrichelte Kurve auf eine Temperatur, die niemand gemessen hat. Akima bleibt flach.

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.

Vom selben Autor

Lizenz: CC BY-NC 4.0