Donnerstag, 18. April 2013

Ringdiagramme mit Canvas

Mein letzter Blog enthielt zwei ringförmige Diagramme, die den politischen Shift in der deutschen Parteienlandschaft verdeutlichen sollten. Diese Diagramme habe ich - da ich auf die Schnelle kein geeignetes Tool fand, mit dem ich sie meinen Vorstellungen entsprechend umsetzen konnte, mit dem HTML5-Element <canvas> erstellt – was zugleich eine nützliche Übung in der Verwendung des Canvas war.

Die Canvas-Graphiken werden im Firefox wie ein gewöhnliches Bild angezeigt und können als solches auch abgespeichert werden. Das habe ich schliesslich getan, um im Blog selbst aus Kompatibilitätsgründen noch PNG-Graphiken verwenden zu können.

In diesem Blog will ich mein Vorgehen beschreiben, um ringförmige Diagramme zu erzeugen. Die Aufgabe ist, in einem Kreisring Abschnitte farblich zu markieren und ggf. mit Texten zu versehen, wie in dieser Graphik:



Der Rahmen: Ein HTML-Dokument mit einem Canvas-Element als alleinigem Content:
<!DOCTYPE html>
<html>
  <head>
    <script type="text/javascript">
    </script>
  </head>
  <body>
    <canvas id="beispiel"/>
  </body>
</html>
Der Scriptblock im Header enthält nun eine Funktion zum Zeichnen der Graphik. Diese Hauptfunktion nutze ich für den Namensraum als Hülle, in der ich alle inneren Funktionen implementiere, ohne dass diese den globalen Namensraum verschmutzen. Um eventuell einmal andere Funktionen als nur die aktuell benötigte Funktion draw() anbieten zu können, liefert die Hauptfunktion einen Hash von benannten Closure-Funktionen zurück: Funktionen, die auf die lokalen Variablen der Funktion zugriffen und diese auch verändern können. Mein Aufruf er Funktion bei onDOMLoaded lautet dann
  AnnularDiagram("beispiel").draw( );
Der Funktionsrumpf, der diesen Aufruf ermöglicht, sieht folgendermassen aus:
  function AnnularDiagram(id) {
  
// Das Canvas-Objekt und den Context merken  
    var canvas = document.getElementById(id);
    var ctx = canvas.getContext('2d');
        
// Rückgabe: Ein Hash von Closures        
    return {
      draw:draw
      };
   
   function draw() {
     }
 
 // ... weitere "private" Funktionen    
      
   }   
Der Context des Canvas-Elements, den ich mir hier in der Variablen ctx merke, ist in allen inneren Funktionen verfügbar - auch beim späteren Aufruf der draw()-Funktion ist er erhalten geblieben. Der Context hört auf alle Funktionen, mit denen in ein Canvas-Element gezeichnet werden kann. Um z.B. den äusseren und den inneren Ring zu zeichnen, verwende ich die arc( )-Funktion des Kontexts. Hierzu brauche ich Koordinaten ox, oy des Mittelpunkts. Die Funktion ctx.arc() die einen Bogen zwischen zwei Winkeln zieht, kapsele ich mir in einer eigenen Funktoin arc(): da ich lieber mit Grad- als mit Bogenmassen arbeiten möchte, nehme ich die Winkelwerte alpha und beta für den Anfangs- und Endpunkt des zu zeichnenden Bogens im Gradmass entgegen und wandele sie mit einer rad()-Funktion um:
  function arc( r, alpha, beta, counter ) {
     ctx.arc(ox,oy,r,rad(alpha),rad(beta), counter);            
     } 
  function rad(alpha) {
    return alpha * Math.PI / 180;
     } 
Wenn ich die Radien des inneren und äusseren Kreises mit r1 und r2 notiere, kann ich in draw() die beiden Kreise zeichnen:
  function AnnularDiagram(id) {
  
// Das Canvas-Objekt und den Context merken  
    var canvas = document.getElementById(id);
    var ctx = canvas.getContext('2d');

    var r1 = 200,
        r2 = 240,
        rm = (r1+r2)/2,
        ox = 400,
        oy = 400;
    function draw() {
      circle(r1);
      circle(r2);
      ctx.stroke();      
      }
    function circle(opt) {
      ctx.moveTo(ox+r,oy);
      ctx.arc(ox,oy,r,0,2*Math.PI);
      }  
  }    
In einer Figur wie dieser sind Polarkoordinaten das richtige Koordinatensystem. Ich schreibe also eine Funktion cart, die polare in rechtwinklige Koordinaten umwandelt,
// Cartesian co-ordinates                  
         function cart(r,phi) {
           return [ cart_x(r,phi), cart_y(r,phi) ];
           }
// Cartesian x co-ordinate         
         function cart_x(r,phi) {
           return ox + r*Math.cos( rad(phi) );
           }  

// Cartesian y co-ordinate         
         function cart_y(r,phi) {
           return oy + r*Math.sin( rad(phi) );
           }             
Meist werde ich die x- und y-Koordinaten als Paar benötigen, also die Funktion cart() aufrufen, während der Aufruf einer einzelnen Koordinatenfunktion eher selten vorkommen dürfte. Da es in Koordinatenumrechnungen wahrscheinlich ist, dass die Variablen x und y verwendet werden, ist es besser die entsprechenden Funktionen etwas anders zu nennen, um keinen Konflikt bei der Namensauflösung zu bekommen. Daher habe ich mich dafür entschieden, die Koordinatenfunktionen cart_x() und cart_y() zu nennen.

Nun kann ich Funktionen moveTo(r,phi) und lineTo(r,phi) auf Basis von Polarkoordinaten anbieten. Das von cart() zurückgelieferten Koordinatenpaar kann ich auf einen Schlag an die kartesischen Pendants ctx.moveTo() und ctx.lineTo() delegieren, weil der Funktionsaufruf mittels apply einen Array als Aufzählung einzelner Funktionsargumente interpretiert:
// Move to a point given in polar co-ordinates           
         function moveTo(r,phi) {
           ctx.moveTo.apply(ctx,cart(r,phi));
           }  

// Draw a line to a point given in polar co-ordinates           
         function lineTo(r,phi) {
           ctx.lineTo.apply(ctx,cart(r,phi));
           }  


Nun aber zum Kernstück der Funktion, der annularRegion(). Zunächst zum Aufruf: Das obige gelbe Segment erzeuge ich durch Aufruf der Funktion annularRegion, deren Parameter ich hoffentlich sprechend genug benannt habe:

  annularRegion({ startAngle:240, 
                  endAngle:300, 
                  color:{r:255,g:255,b:0,alpha_max:1},
                  sat:sat4,
                  segments:200
                  });
Hier benutze ich die benannte Parameterübergabe in Form eines Option-Hashs. Idealerweise sollte eine Funktion höchstens einen Aufrufparameter haben. Wenn - wie hier - eine ganze Reihe benötigt wird, sollten diese als benannte Parameter übergeben werden. Für eine Sprache wie JavaScript, die keine benannte Parameterübergabe anbietet, simulieren wir diese Übergabe in Form eines Hashs. Da man Hashs sehr bequem on-the-fly mit geschweiften Klammern erzeugen kann, müssen wir nur ({ und }) statt ( und ) - und schon haben wir die Parameterübergabe by name. Dies ist mittlerweile ein gängiges Muster in der JavaScript-Programmierung.

Doch nun zu den einzelnen Parametern der Funktion annularRegion().

startAngle und endAngle bezeichnen Beginn und Ende des zu zeichnenden Segments. Danach kommt die Farbe als rgb-Farbwert, zusammen mit einem maximalen alpha-Wert für die gewünschte maximale Saturierung der Farbe: alpha = 1 ergibt in diesem Fall ein "maximal gelbes" Gelb. In der Mitte des Abschnitts soll genau dieser Sättigungsgrad alpha_max verwendet werden. Zu den Rändern hin soll der alpha-Farbwert allmählich abnehmen, bis er an den Rändern Null ist. Hierzu verwende ich einen Parameter sat, der eine Funktion erwartet. In diesem Fall übergebe ich mit sat4 einen quartischen Kern:

  function sat4(x) {
     var t = (1-x*x);
     if (t<0) return 0;
     return t*t;
     }  


Ich habe auch noch zwei Kerne für einen linearen An- und Abstieg des Sättigungsgrads, mit den Extremen an den Rändern des Segments:

// Increasing linearly from 0 to 1.          
 function sat1(x) {
    if (x < -1 || x > 1) return 0;
    return (x+1)/2;
    }
            
// Decreasing linearly from 0 to 1.          
 function sat1minus(x) {
    if (x < -1 || x > 1) return 0;
    return (-x+1)/2;
    }   
Warum habe ich mich eigentlich selbst mit der Variation des Sättigungsgrades beschäftigt? Weil es zwar eingebaute sogenannte Gradienten hierfür gibt, diese aber für ringförmige Bereiche anscheinend nicht anwendbar sind. Es gibt einen radialen Gradienten für einen allmählichen Anstieg längs einer radialen Achse, aber keinen zirkularen Gradienten, bei dem der alpha-Wert längs eines Kreisbogens ansteigt, wie ich ihn hier benötigt hätte.

Um ein Segment zu bilden und mit einem festen Farbwert zu füllen, muss man die Region zunächst mit lineTo- und arc-Befehlen umfahren und danach die fill-Funktion aufrufen:
     ctx.beginPath();
     ctx.fillStyle = "rgba("+opt.color.r+","
                     +opt.color.g+","
                     +opt.color.b+","
                     +opt.color.alpha+")";
     ctx.strokeStyle = ctx.fillStyle;
     moveTo(r1,opt.startAngle);
     lineTo(r2,opt.startAngle);
     arc(r2,opt.startAngle,opt.endAngle);
     moveTo(r2,opt.endAngle);
     lineTo(r1,opt.endAngle);
     arc(r1,opt.endAngle,opt.startAngle,true);
     moveTo(r1,opt.startAngle);
     ctx.closePath();
     ctx.fill();
Wird nun ein Sättigungsgrad angegeben, so rufe ich diese Teilfunktion für jedes einzelne Segment auf. Insgesamt sieht die Funktion daher folgendermassen aus:
 function annularRegion(opt) {
   var segments = opt.segments;
   if (!segments && opt.color.alpha === undefined) opt.color.alpha = 1;  // default
   var alpha_max = opt.color.alpha_max || 1;
   if (opt.segments) {
     var i, alpha = opt.startAngle, beta = opt.endAngle;
     opt.segments = 0;
     for (i=0;i<segments;i++) {
       opt.color.alpha = opt.sat(i/segments - 0.5)*opt.color.alpha_max;
       opt.startAngle = alpha + (beta-alpha)*i/segments
       opt.endAngle  = opt.startAngle + (beta-alpha)/segments
       annularRegion(opt);
       }
     }
   else {
     ctx.beginPath();
     ctx.fillStyle = "rgba("+opt.color.r+","
                     +opt.color.g+","
                     +opt.color.b+","
                     +opt.color.alpha+")";
     ctx.strokeStyle = ctx.fillStyle;
     moveTo(r1,opt.startAngle);
     lineTo(r2,opt.startAngle);
     arc(r2,opt.startAngle,opt.endAngle);
     moveTo(r2,opt.endAngle);
     lineTo(r1,opt.endAngle);
     arc(r1,opt.endAngle,opt.startAngle,true);
     moveTo(r1,opt.startAngle);
     ctx.closePath();
     ctx.fill();
     }    
   }
Wer mag, kann sich meine Testseite auf jsfiddle anschauen, mit den Aufrufen spielen und den Code nach Belieben modifizieren oder weiterentwickeln. Viel Spass!

Annular Diagrams with Canvas (jsfiddle)

Keine Kommentare :