HTML5 Canvas – Timeline Diagramm erstellen


Da ich für ein privates Webprojekt ein Diagramm benötige um verschiedene Abläufe eines Tages abzubilden habe ich mir die Möglichkeiten angeschaut wie man über HTML5 Bilder/Animationen darstellen kann. Dabei standen mir zwei Technologien zur Verfügung.

1. HTML5 SVG Element (Scalable Vector Graphics)

Bei dieser Technologie kann man direkt auf bereits bestehende Mausevents zurückgreifen um z.B. Animationen oder Click Events abzubilden. Denn hier wird jede Linie (Grafikelement) im DOM unseres aktuellen HTML Dokuments abgelegt und kann daher auch recht “einfach” mit Hilfe von JavaScript manipuliert werden. Hier besteht aber ein Problem bei komplexen Diagrammen, da alle Elemente im DOM abgelegt, kann es hier sehr schnell zu Performance Problemen kommen.

2. HTML5 Canvas Element

Hier handelt es sich um eine einfache Fläche der Größe X mal Y auf der man mit Hilfe einfacher Befehle Linien, Texte, einfache Formen und Kurven zeichnen kann. Hier stehen keine nativen Events zur Verfügung, denn wenn man etwas zeichnet, dann weiß das Element nach dem Zeichnen der Linie nicht mehr das es da eben eine Linie gezeichnet hat. Wenn man hier Events einbinden möchte muss man diese in Handarbeit selbst erstellen oder auf bestehende JavaScript Libraries wie kinectJs zurückgreifen die diese Events bereits “handlich” in ein Framework verpackt haben.

Ich habe mich für das Canvas Elemente zur Umsetzung meines Timeline Diagramm entschieden und da ich hier keine Animationen benötige, sondern nur Elemente zeichnen möchte, habe ich auch keine zusätzliche Bibliothek verwendet.

Umsetzung des Timeline Diagramms mit Canvas

Das folgende Ergebnis wollen wir am Ende durch das Zeichnen erreichen.

image

Ein wichtiger Aspekt beim Zeichnen mit Canvas und Linien/Rechtecken ist das hier standardmäßig eine “Art Antialiasing” stattfindet, d.h. da wir mit einer 1px breiten Linien zeichnen und wir nicht aufpassen, dann kann eine Linie die 1px breit sein soll aussehen wie eine 2px breite verschwommene Linie. Im Folgenden Beispiel sind beide Linien im Original 1px breit.

image

der passende JavaScript Code für die beiden Linien:

//Das Canvas Element heraussuchen
var canvas = document.getElementById('timeCanvas2'),
//Den aktuellen Kontext ermitteln
ctx = this.canvas.getContext('2d'); 

ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(10, 10);
ctx.lineTo(100, 10);
ctx.stroke();

ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(10, 15.5);
ctx.lineTo(100, 15.5);
ctx.stroke();

Der einzige Unterschied ist, dass wir die zweite Linie die auch wirklich nur 1px breit gezeichnet wird nicht auf 15.0 zeichnen sondern auf 15.5, d.h. alle Linien die ich im Diagramm zeichne werden, egal ob auf der X-Achse oder Y-Achse immer auf X.5 oder Y.5 gezeichnet, um das “Antialiasing” zu umgehen.

1. Zeichnen des Diagramms

Als erstes benötigen wir eine Funktion die uns das Diagramm zeichnet und alle veränderbaren Variablen bereitstellt. Ich habe die einzelnen Funktionen zum Zeichnen in Prototype Funktionen “ausgelagert”.

image

Basisklasse “TimeDiagram” für diese werden dann die passenden prototype Funktionen erstellt. Ich habe versucht alle Optionen dementsprechend zu beschriften. Die Diagrammeinträge (diagramTypes) können beliebig erweitert werden (die Legende passt sich dann automatisch an).

//Zeitdiagramm Contextvariablen zum Anpassen.
function TimeDiagram() {
    //Die unterschiedlichen Diagrammtypen mit den passenden Farben und Legendennamen
    this.diagramTypes = {
        Work: { value: 0, name: 'Arbeitszeit', gradStart: '#5BC0DE', gradEnd: '#339BB9' },
        Pause: { value: 10, name: 'Pause', gradStart: '#FBB450', gradEnd: '#F89406' },
        Holiday: { value: 20, name: 'Urlaub', gradStart: '#EE5F5B', gradEnd: '#C43C35' },
        Project: { value: 30, name: 'Projektzeit', gradStart: '#62C462', gradEnd: '#57A957' }
    },
    //Die Beschriftung der X-Achse
    this.times = ['00:00', '01:00', '02:00', '03:00', '04:00', '05:00', '06:00', '07:00', '08:00',
                  '09:00', '10:00', '11:00', '12:00', '13:00', '14:00', '15:00', '16:00', '17:00',
                  '18:00', '19:00', '20:00', '21:00', '22:00', '23:00'],
    this.fontsize = 10, //Die Schriftgröße für die Beschriftung
    this.leftRightBorder = 20, //Der Abstand zum linken und rechten Rand des Diagramms
    this.topBottomBorder = 20, //Der Abstand zum oberen und unteren Rand des Diagramms
    this.diagramColor = '#ccc', //Farbe des diagramms
    this.ctx = undefined, //Der Aktuelle Kontext, der dieser wird in DrawDiagramm gesetzt.
    this.canvWidth = undefined,
    this.canvHeight = undefined,
    this.diagramEntryHeight = 15, //Die Höhe der Zeiteinträge im Diagramm
    this.diagramEntryOffset = 3, //Der Abstand zwischen den Balken im Diagram
    this.diagramStartPosition = 10, //Die Startposition der Balkeneinträge im Diagramm
    this.LegendYPos = 10, //Die Y-Position unserer Legende (Achtung 0,0 liege Links Oben)
    this.LegendXPos = 10, //Die X-Position unserer Legende
    this.ctxAlpha = 0.75;  //Die globalen Alpha Einstellungen
};

Dan wird eine Funktion zum Zeichnen einer Linie benötigt, hier habe ich die Standardbefehle vom Canvas noch einmal in eine Funktion verpackt.

//Funktion zum Zeichnen einer Linine
TimeDiagram.prototype.drawLine = function (ctx, startX, startY, endX, endY) {
    ctx.beginPath();
    ctx.moveTo(startX, startY);
    ctx.lineTo(endX, endY);
    ctx.stroke();
};

Im Folgenden dann die Prototype Funktion zum Erstellen des Diagramms, hier werden die einzelnen Achsen erstellt, diese werden anhand der Größe des Canvas Elements berechnet und entsprechend erstellt. Es muss vor allem auf “leftRightBorder” geachtet werden, das der Abstand auch überall mit einberechnet wird. Außerdem verwende ich “Math.round(Zahl) + 0.5” um das Antialiasing Problem zu umgehen.

//Funktion zum Erstellen des Diagramms
TimeDiagram.prototype.drawDiagram = function (canvasId) {
    //Achtung wir müssen hier die Reihenfolge einhalten, das auch alle Variablen Initialisiert sind.
    //Erst in der drawDiagram Funktion wird der Context und das Canvas geladen.
    this.canvas = document.getElementById(canvasId); //Das Canvas Element heraussuchen
    this.ctx = this.canvas.getContext('2d'), //Den aktuellen Kontext ermitteln
    this.canvWidth = this.canvas.width, //Die Breite unseres Canvas bestimmen
    this.canvHeight = this.canvas.height; //Die Höhe unseres Canvas bestimmen

    var count = 0,
        //Den abstand zwischen den Elementen anhand der Breite des Canvas bestimmen und ACHTUNG das hier die Border ebenfalls mit abgezogen wird.
        abstand = (this.canvWidth - 2 * this.leftRightBorder) / (this.times.length), 
        quarter = Math.round(abstand / 4), //Den Abstand für eine Viertelstunde berechnen
        textPos = this.canvHeight - (this.topBottomBorder / 2) + 3; //Die Textposition für die Uhrzeit bestimmen

    this.ctx.font = 'normal ' + this.fontsize + 'px Calibri';
    this.ctx.strokeStyle = this.diagramColor;
    this.ctx.lineWidth = 1;

    //Die X-Achse zeichnen
    this.drawLine(this.ctx, this.leftRightBorder - 5, this.canvHeight - this.topBottomBorder, this.canvWidth - this.leftRightBorder, this.canvHeight - this.topBottomBorder);
    var counter = 0;
    //Y-Achsen erstellen Achtung hier muss bei den "i" Werten x-Position darauf geachtet werden das 0.5 dazu addiert wird damit das Antializing "deaktiviert" wird.
    for (var i = this.leftRightBorder; counter++ < this.times.length; i = i + abstand) {
        //Der wert für i wird zwar gerundet, sollte aber nicht direkt auf i passieren, damit der Wert nicht bei jedem Durchgang weiter verändert wird.
        var yPos = Math.round(i) + 0.5;
        //Stundenlinien erstellen
        this.drawLine(this.ctx, yPos, this.canvHeight - this.topBottomBorder + 5, yPos, this.topBottomBorder),
        //Erstellen der Virtelstundenanzeigen
        this.drawLine(this.ctx, yPos + quarter, this.canvHeight - this.topBottomBorder + 3, yPos + quarter, this.canvHeight - this.topBottomBorder - 3),
        this.drawLine(this.ctx, yPos + 2 * quarter, this.canvHeight - this.topBottomBorder + 5, yPos + 2 * quarter, this.canvHeight - this.topBottomBorder - 5),
        this.drawLine(this.ctx, yPos + 3 * quarter, this.canvHeight - this.topBottomBorder + 3, yPos + 3 * quarter, this.canvHeight - this.topBottomBorder - 3),
        //Uhrzeit anzeigen unterhalb der X-Achse
        this.ctx.fillText(this.times[count++], yPos - this.fontsize, textPos);
    }
};

Das Diagramm erstellen wir, in dem man ein neues Objekt von unserem TimeDiagram anlegt und drawDiagram aufruft.

var diagram = new TimeDiagram();
//Das Diagramm zeichnen
diagram.drawDiagram('timeCanvas');

2. Zeichnen der Diagrammdaten

Nachdem wir das Diagramm erfolgreich erstellt haben, muss dieses mit Daten gefüllt werden und sieht dann z.B. folgendermaßen aus:

image

Damit wir die passenden Daten an unsere Funktion zum Erstellen von Diagrammeinträgen übergeben können, habe ich noch einen Datentyp angelegt “TimeEntry”:

//Ein Zeiteintrag für das Diagramm
function TimeEntry(startMinutes, endMinutes, diagramType) {
    this.startMinutes = startMinutes, //Die Startzeit in Minuten von 00:00
    this.endMinutes = endMinutes, //Die Endezeit in Minuten von 00:00
    this.type = diagramType; //Der Typ des Eintrags 'diagramTypes'
}

Die prototype Funktion “drawTimeEntries” für unser TimeDiagram kann z.B. mit den Folgenden Werten aufgerufen werden:

var entries = [new TimeEntry(45, 240, diagram.diagramTypes.Work), new TimeEntry(120, 600, diagram.diagramTypes.Project),
               new TimeEntry(820, 960, diagram.diagramTypes.Project), new TimeEntry(320, 1140, diagram.diagramTypes.Work),
               new TimeEntry(520, 1200, diagram.diagramTypes.Holiday), new TimeEntry(60, 140, diagram.diagramTypes.Pause)];
//Die Zeiteinträge zeichnen.
diagram.drawTimeEntries(entries);

Wir übergeben dabei eine Liste (Array) mit TimeEntries an unsere “drawTimeEnties” Funktion. Wichtig ist hier das Einträge mit dem gleichen Diagrammtyp z.B. “Project” immer auf der gleichen Ebene (Y-Position) erstellt werden, damit sich die Einträge nicht überlagern.

//Funktion zum Erstellen der Zeitblöcke
TimeDiagram.prototype.drawTimeEntries = function (timeEntries) {
    var yPosByType = [],
        //Die YPostion für den übergebenen Typen ermitteln, wenn diese noch nicht vorhanden ist
        //wird der Typ der Listehinzugefügt.
        addYposType = function (type, index) {
            var diagramIdx = index;
            for (var i = 0; i < yPosByType.length; i++) {
                //Wenn der Typ gefunden wurde, dann die passende Position zurückgegeben.
                if (yPosByType[i].typ === type) {
                    return yPosByType[i].pos;
                }
            }

            var newPos = (yPosByType.length * (diagramIdx.diagramEntryHeight + diagramIdx.diagramEntryOffset));
            //Der Typ ist noch nicht enthalten, also muss er hinzugefügt werden
            yPosByType.push({ typ: type, pos: newPos });
            return newPos;
        };

    //Für jeden Eintrag das passende "viereck" zeichnen
    for (var i = 0; i < timeEntries.length; i++) {
        //Berechnen bei welchem Pixel das Zeichnen beginnen soll dafür werden die Minuten für einen Tag 1440 als 100% Maß genommen. 
        //ACHTUNG hier muss die Border von der Gesamtbreite abgezogen werden
        var startPix = Math.round(((this.canvWidth - 2 * this.leftRightBorder) * timeEntries[i].startMinutes / 1440) + this.leftRightBorder) + 0.5,
            //Beim Ende eines Eintrags, berechnen wir als erstes die Gesamtlänge vom Start des Diagramms, hier muss dann die Startzeit abgezogen werden um das tatsächliche Ende zu erhalten.
            endPix = Math.round(((this.canvWidth - 2 * this.leftRightBorder) * (timeEntries[i].endMinutes - timeEntries[i].startMinutes) / 1440)) + 0.5,
            //Die yPos wir anhand der X-Achse bestimmt und da wir von Links oben "zählen" mit 0,0 müssen hier die Werte entsprechend berechnet werden.
            //Außerdem muss die Position je nach dem welcher entryType angezeigt werden soll um Y Pixel verschoben werden, mit 'addYposType'
            yPos = (this.canvHeight - this.topBottomBorder - this.diagramStartPosition - this.diagramEntryHeight - addYposType(timeEntries[i].type, this)) + 0.5;

        //Erstellen Des Arbeitszeit eintrags mit den passenden Position und Farbe
        this.drawRectangle(this.ctx, startPix, yPos, endPix, this.diagramEntryHeight,
            timeEntries[i].type.gradStart, timeEntries[i].type.gradEnd);
    }
};

Es wird eine extra Funktion die das Erstellen eines Rechtecks kapselt benutzt um die Anzahl der Zeilen zu reduzieren, außerdem nutze ich noch einen Lineargradienten um die Balken “schöner” zu machen.

//Funktion zum Zeichnen eines Rechtecks
TimeDiagram.prototype.drawRectangle = function (ctx, startX, startY, width, height, gradStart, gradEnd, borderColor, borderWidth) {
    //Zeichnen eines Diagrammeintrags/Rechtecks
    ctx.globalAlpha = this.ctxAlpha;
    ctx.beginPath();
    //Erst das Rechteck zeichnen
    ctx.rect(startX, startY, width, height);
    //Dann im Rechteck den passenden Lineargradienten für die Farben einfügen
    var grd = ctx.createLinearGradient(startX, startY, startX, startY + height);
    grd.addColorStop(0, gradStart);
    grd.addColorStop(1, gradEnd);
    this.ctx.fillStyle = grd;
    this.ctx.fill();

    //Prüfen ob die Parameter auch der Funktion übergeben wurden.
    if (borderColor !== undefined && borderWidth !== undefined) {
        ctx.lineWidth = borderWidth;
        ctx.strokeStyle = borderColor;
        ctx.stroke();
    }
};

3. Hinzufügen einer Legende

Damit man auch weiß was auf dem Diagramm dargestellt wird, wird eine passende Legende benötigt. Die Position der Legende kann über die Optionen im TimeDiagram eingestellt werden (LegendYPos und LegendXPos).

image

Für das Erstellen der Legende wird erneut das Array mit den TimeEntries mit übergeben, damit wir wissen welche Typen auch auf dem Diagramm angezeigt werden sollen und welche nicht in der Legende dargestellt werden sollen.

//Erstellen einer Legende für unser Diagramm
TimeDiagram.prototype.drawLegend = function (timeEntries) {
    //Prüfen ob der Typ der übergeben wurde in unserer Zeitliste der Einträge die angezeigt werden enthalten ist.
    var checkTimeEntriesForType = function (typeToCheck) {
        for (var i = 0; i < timeEntries.length; i++) {
            //Nur wenn der Typ gefunden wurde, dann wird dieser auch angezeigt.
            if (timeEntries[i].type === typeToCheck) {
                return true;
            }
        }
        return false;
    },
        //Zählt wie viele unterschiedliche timeEntries/diagramTypes in der übergebenen Liste enthalten sind.
        countTimeEntryTypes = function () {
            var anz = [];
            for (var i = 0; i < timeEntries.length; i++) {
                //Prüfen ob der Typ schon in unserer Liste enthalten ist.
                if(anz.indexOf(timeEntries[i].type) === -1) {
                    anz.push(timeEntries[i].type);
                }
            }
            //Die anzahl der Listeneinträge zurückgegeben
            return anz.length;
        };

    //Die Umrahmung und den Hintergrund für unsere Legende festlegen
    this.drawRectangle(this.ctx, this.LegendXPos - 2.5, this.LegendYPos - 2.5, 125, countTimeEntryTypes() * 15, '#F5F5F5', '#F5F5F5', '#ccc', 1);
    //Die yPos setzten, da diese verändert wird darf nicht this.LegendYPos verwendet werden.
    var yPos = this.LegendYPos;

    //Alle Properties unserer Diagrammtypen durchgehen und mit den Werten in den Zeiteinträgen vergleichen, damit wir wissen welche auch im Diagramm angezeigt werden.
    for (var prop in this.diagramTypes) {
        //Wenn es sich um ein Property handelt, welches zu unserem Diagrammtyp gehört, prüfen ob dies ebenfalls in timeEntries enthalten ist.
        if (this.diagramTypes.hasOwnProperty(prop) && checkTimeEntriesForType(this.diagramTypes[prop])) {
            //Die Farbe unseres Eintrags an der richtigen Stelle erstellen
            this.drawRectangle(this.ctx, this.LegendXPos, yPos, 10, 10,
                this.diagramTypes[prop].gradStart, this.diagramTypes[prop].gradEnd);

            this.ctx.fillStyle = 'black';
            //Den Namen unseres Eintrags neben die Farbe schreiben
            this.ctx.fillText(this.diagramTypes[prop].name, this.LegendXPos + 15, yPos + 9);

            //Die nächste Position berechnen für den nächsten Eintrag.
            yPos = yPos + 15;
        }
    }
};

Aufruf zum Zeichnen unserer Legende:

 //Erstellen der Legende
 diagram.drawLegend(entries);

4. Zusammenfassung

Zum einfachen Zeichnen eines Diagramms benötigt man für das Canvas Element noch keine erweiterte JavaScript Bibliothek, hier ist nur ein einfaches Mathematisches Verständnis notwendig um die passenden Linien an die richtige Stelle zu zeichnen. Da ich das erste mal mit Canvas gearbeitet habe, kann ich leider nicht beurteilen ob ich es mir an einer Stelle besonders schwierig gemacht habe oder ob man das ganze anders angeht. Für Tipps bin ich immer offen.

Quelle:

http://www.html5canvastutorials.com/tutorials/html5-canvas-tutorials-introduction/

Codeplex:

Dann unter “Source Code” –> “Browse” –> “Testprojekte” –> “Mvc4WebApiKoTb” hier dann die Wichtigste Datei im Webprojekt unter “Views/Home/Html5Canvas.cshtml”. Das Projekt sollte auch so lauffähig sein.

Advertisements

2 Gedanken zu „HTML5 Canvas – Timeline Diagramm erstellen

  1. Chris

    Hey super Diagramm! Ist es mit der aktuellen Version möglich die Legende erst zu erstellen und rechts daneben erst das Diagramm? Also dass die Legende links von dem Diagramm sitzt, jetzt ist sie mitten über den Balken.

    Vielen Dank aber schonmal :)

    Gefällt mir

    Antwort
    1. SquadWuschel Autor

      Hi,

      hier muss ich leider gestehen, das ich hier seid Jahren nichts mehr gemacht habe. Aber prinzipiell sollte das auf jedenfall möglich sein, da ich ja eh alles selbst erstelle. Aber im Detail kann ich dir aktuell leider nicht weiterhelfen, hier müsste ich mich ebenso wie du auch wieder in den Code einarbeiten.

      Tipp: Ich habe in der Finalen Version in meinem Projekt dann doch noch von Canvas auf SVG umgestellt, was keinen Großen Aufwand darstellte, aber damit war es dann auch Möglich Tooltips für die einzelnen Legendenteile einfacher einzublenden, da man dann direkt auf Mouse Events zurückgreifen kann, im SVG ist das um einiges schwieriger.

      MFG
      SquadWuschel

      Gefällt mir

      Antwort

Kommentar verfassen

Trage deine Daten unten ein oder klicke ein Icon um dich einzuloggen:

WordPress.com-Logo

Du kommentierst mit Deinem WordPress.com-Konto. Abmelden / Ändern )

Twitter-Bild

Du kommentierst mit Deinem Twitter-Konto. Abmelden / Ändern )

Facebook-Foto

Du kommentierst mit Deinem Facebook-Konto. Abmelden / Ändern )

Google+ Foto

Du kommentierst mit Deinem Google+-Konto. Abmelden / Ändern )

Verbinde mit %s