protofunc()

Die jQuery UI-Widget-Factory am Beispiel einer Canvas Map: Teil I

Tags: deutsch, javascript, jquery, tutorial

Wir müssen nicht mehr lange warten und jQuery UI 1.5 wird ist released. Neben einigen Komponenten und hübschen Effekten, bietet der Core eine – für Plugin-Autoren – interessante Widget-Factory, welche ich mal am Beispiel einer Canvas Imagemap näher unter die Lupe nehmen möchte. Als erstes sollte man sich daher die neuesten Versionen von jQuery, UI und explorercanvas (wir wollen ja, dass die Beispiele auch ein bisschen im IE funktionieren.) besorgen. Hier findet ihr eine – zugegebenermaßen – häßliche Demo des einfachen CanvasMap-Plugins (ich bin kein Grafiker und scheitere bereits bei Imagemaps :-) .

Die HTML Struktur und das CSS

Bevor wir anfangen solltet ihr einen kleinen Blick in das HTML werfen. Ihr werdet sehen, dass wir erstens ein Bild mit der Karte Berlins haben und zweitens ein transparentes Bild, welches die eigentliche ImageMap darstellt. Der Grund hierfür ist einfach. Canvas erzeugt anders als SVG richtige Pixelgrafixen. Man kann hier leider keine Events, wie beispielsweise mouseover gezielt abfangen. Wir brauchen daher eine darüber liegende Interaktionsebene für den User.

Was macht die Widget-Factory?

Im Prinzip macht die Widget-Factory nicht sehr viel, aber sie macht es geschickt. Sie erstellt aufgrund eines – von uns zu definierenden – „Prototypen-Objekts“ ein ordinäres jQuery-Plugin und eine JS-Klasse. Das jQuery-Plugin dient hierbei als Initialisierungs- und Zugriffs-“Controller“ für die definierte Klasse. Um das folgende Tutorial zu verstehen sollte man also Grundkenntnisse in JavaScript-OOP besitzen.

Der Grundaufbau eines jQuery Widgets könnte so aussehen:

//Namespace erstellen, falls noch nicht vorhanden
$.namespace = $. namespace || {};
//Widget erstellen
$.widget('namespace.pluginname',
	{
		instancemethodeA: function(){
		},
		instancemethodeB: function(){
		},
	}
);
// default Optionen für das Widget
$.namespace.pluginname.defaults = {
	defaultOptionA: 'Hello',
	defaultOptionB: ' World'
};

Die Widget-Factory erstellt uns nun die gewünschte Klasse und das jQuery-Plugin:
Mit folgendem Ausdruck können wir unsere Klasse instantiieren:

$('#map').pluginname({defaultOptionB: ' mein Freund'});

Innerhalb unserer Instanzmethoden können wir auf das #map-Element mit this.element (Es handelt sich hierbei um ein jQuery-Objekt von #map.) und auf die Optionen mit this.options zugreifen (d.h. also this.options.defaultOptionB enthält den String ‘ mein Freund’ und defaultOptionA ist weiterhin der default-Wert ‘Hello’).
Wenn wir unsererem Widget ganz zu Anfang noch einige Dinge mitgeben möchten wie z.B. CSS-Klassen setzen, Events-Binden, zusätzliche Elemente hinzufügen, können wir eine Instanzmethode namens init definieren. Diese wird automatisch vom Constructor unserer Klasse aus aufgerufen (Den Constructor selbst liefert die Widget-Factory.).

Startcode unserers Canavasmap-Widgets

Wir wollen später das unser Widget wie folgt aufgerufen werden kann:

$('#map').canvasmap();

Das #map-Element ist hierbei nicht die Imagemap selbst, sondern ein Container, welcher die Bilder und die Imagemap umfasst.

$.pf = $.pf || {};
$.widget("pf.canvasmap", {
	init: function(){
		var that = this;
		this.canvas = $(document.createElement('canvas'));
		this.img = $('img[usemap]:first', this.element[0]);
		var ieDiv = $('<div class="canvas"><div>').insertBefore(this.img[0]);
		ieDiv.prepend(this.canvas[0]);
		if($.browser.msie && this.canvas[0] && !this.canvas[0].getContext && typeof G_vmlCanvasManager != 'undefined'){
			 this.canvas = $(G_vmlCanvasManager.initElement(this.canvas[0]));
		}
		if(this.canvas[0] && this.canvas[0].getContext){
			this.ctx = this.canvas[0].getContext('2d');
			this.areas = $('area', this.element[0]);

			if(this.options.clickBorderWidth || this.options.clickBG){
				this.img.bind('click.canvasImagemap', click);
				this.areas.bind('click.canvasImagemap', click);
			}

			if(this.options.overBorderWidth || this.options.overBG){
				this.img.bind('mouseover.canvasImagemap', over).bind('focus.canvasImagemap', over);
				this.areas.bind('focus.canvasImagemap', over).bind('mouseenter.canvasImagemap', over);
				this.img.bind('mouseout.canvasImagemap', out);
				this.areas.bind('blur.canvasImagemap', out).bind('mouseleave.canvasImagemap', out);
			}
		}
	}
});
$.pf.canvasmap.defaults = {
	overBG: 'rgba(0,185,227,0.5)',
	overBorderColor: 'rgba(255,255,255,0.5)',
	overBorderWidth: 1,
	clickBG: 'rgba(0,185,227,1)',
	clickBorderColor: 'rgba(255,255,255,1)',
	clickBorderWidth: 2
};

Als erstes erstellen wir unseren Namespace (pf für pfrotofunc) und definieren die 1. Methode sowie unsere Defaults für unser Plugin namens canvasmap. In der Initialiserungsmethode erstellen wir ein Canvas-Element fügen es in einen div-Container und platzieren es vor das Imagemapbild. Der Container ist für das später auszuführende explorercanvas notwendig. Als nächstes überprüfen wir, ob das Canvas-Malobjekt (this.canvas[0].getContext) zur Verfügung steht.
Wenn dem nicht so ist, haben wir es wahrscheinlich mit dem IE zu tun und rufen G_vmlCanvasManager.initElement auf dem Element auf, wandeln es in ein jQuery-Objekt und speichern es in unsere canvas Eigenschaft.

Nach einer weiteren „Sicherheitsabfrage“ speichern wir unser Malobjekt in this.ctx und binden unsere Events.

Da die Browser recht unterschiedlich mit Events bei Imagemaps umgehen, belegen wir sowohl das Bild als auch die einzelnen areas der Imagemap mit dem click und mouse-over/out – Event und da wir unobtrusiv und barrierearm arbeiten, stellen wir den Mausevents noch focus- und blur-Events zur Seite.

Der aufmerksame Betrachter wird feststellen, dass die Eventhandler, die gebindet wurden, noch gar nicht existieren. Hierzu kommen wir aber zum Schluss zurück, wenn der übrige Aufbau unserer Klasse feststeht.

Die weiteren Instanzmethoden

Die nachfolgenden Methoden werden – wie die init-Methode – ebenfalls im Canvas-Prototypen-Objekt gespeichert und mit der $.widget-Factory in eine Instanzmethode unserer $.pf.canvasmap-Klasse umgewandelt.

1. Die getArea-methode

Da wir – wie oben gesehen haben – unsere Events auch auf das Bild gelegt haben, aber wir natürlich mit der jeweiligen area-Arbeiten müssen (hier stehen nämlich die wesentlichen Informationen zum Malen drin.), müssen wir aus unserem Event die area extrahieren.

getArea: function(e, elm){
	var area = $(e.target);
	if(!area[0] || !area.is('area')){
		area = $(elm);
		if(!area[0] || !area.is('area')){
			area = false;
		}
	}
	return area;
}

Die getArea-Methode bekommt sowohl das Event-Objekt als auch das DOM-Element von unserem Eventhandler übergeben und muss nun entscheiden, welches von beidem die gewünschte Image-Map area enthält. Sofern sie keine area findet, ist irgendetwas schief gelaufen und sie gibt false zurück.

2. Die getCoords-Methode

Aus unserer Area müssen wir nun die einzelnen Koordinaten, welche im Attribut coords durch Komma separiert gespeichert sind, extrahieren. Daneben müssen wir das ganze noch in Zahlen umwandeln. Wir speichern das ganze dann in ein Array ab und geben es zurück.

getCoords: function(area){
	var coords = area.attr('coords').split(','), numCoords = [];
	for(var i = 0, len = coords.length; i < len; i++){
		numCoords[i] = parseInt($.trim(coords[i]), 10);
	}
	return numCoords;
}

3. Die drawArea-Methode

Nun sind wir kurz vor dem eigentlichen Mal-Vorgang. Wir benötigen für unser Beispiel nur den Shape-Typ ‘poly’, wir wollen aber später evtl. noch weitere Typen hinzufügen. Daher enthält unsere drawArea-Methode gar nicht den eigentlichen Mal-Code, sondern lediglich die Entscheidung, ob wir den Shape-Typen unterstützen und welche Mal-Methode aufgerufen werden soll:

drawArea: function(area, options){
	if(area){
		var shapeStyle = (area.attr('shape'))?area.attr('shape').toLowerCase():false;
		if(shapeStyle && this[shapeStyle]){
			var coords = this.getCoords(area);
			this[shapeStyle](coords, options);
		}
	}
}

Die Methode extrahiert also den Shape-Typ aus der Area, überprüft ob wir eine solche Methode definiert haben und wenn ja übergibt drawArea die Koordinaten sowie die Maloptionen an die eigentliche Mal-Methode.

4. Die poly-Methode

Nun malen wir aber wirklich:

poly: function(coord, o){
	this.ctx.beginPath();
	this.ctx.moveTo(coord[0],coord[1]);
	for(var i = 2, len = coord.length; i < len; i++){
		this.ctx.lineTo(coord[i],coord[i+1]);
		i++;
	}
	this.ctx.lineTo(coord[0],coord[1]);
	if(o.bg){
		this.ctx.fillStyle = o.bg;
		this.ctx.fill();
	}
	if(o.borderWidth){
		this.ctx.strokeStyle = o.borderColor;
		this.ctx.lineWidth = o.borderWidth;
		this.ctx.stroke();
	}
}

Als erstes sagen wir unserem Malobjekt, was wir malen wollen. Dann bewegen wir unseren Zeichenstift zur ersten Koordinate. Danach gehen wir das Array bei 2 beginnend, immer um zwei Schritte durch und ziehen unsere Linien. Sobald wir hiermit fertig sind, ziehen wir unsere Verbindungslinie zwischen End- und Anfangspunkt. Und zuletzt Füllen wir unsere gezeichneten Linien unseren übergebnenen Optionen (o) entsprechend aus und zeigen eine Border an.

5. Die drawClickArea-Methode

Um den Sinn der letzten Methode zu verstehen, muss man etwas zu canvas wissen. Es handelt sich wie gesagt um Pxielgrafiken. Man kann also nicht einfach sein Malobjekt verändern, sondern muss mit jedem Schritt das gesamte Bild löschen und von neuem anfangen. Gleichzeitig wollen wir aber, dass ein angeklickter Bereich auch dann noch gehighlightet wird, wenn man mit der Maus drüber fährt. Dafür speichern wir unseren geklickten Bereich in unserer Objektinstanz unter der Eigenschaft ‘ clickArea’ ab und rufen diese Methode beim hovern regelmäßig auf.

drawClickArea: function (){
	this.drawArea(this.clickArea, {
		bg: this.options.clickBG,
		borderColor: this.options.clickBorderColor,
		borderWidth: this.options.clickBorderWidth
	});
}

Nun haben wir alle Methoden zusammen und können uns den noch fehlenden Event-Handlern widmen:

function over(e){
	var area = that.getArea(e, this);
	that.ctx.clearRect(0,0,that.cWidth,that.cHeight);
	that.drawArea.call(that, area, {
		bg: that.options.overBG,
		borderColor: that.options.overBorderColor,
		borderWidth: that.options.overBorderWidth
	});
	that.drawClickArea.call(that);
	e.preventDefault();
	return false;
}
function out(e){
	that.ctx.clearRect(0,0,that.cWidth,that.cHeight);
	that.drawClickArea.call(that);
}
function click(e){
	var area = that.getArea(e, this);
	that.clickArea = area;
	that.ctx.clearRect(0,0,that.cWidth,that.cHeight);
	that.drawClickArea.call(that);
	e.preventDefault();
	return false;
}

Wie ihr seht löschen wir innerhalb dieser Funktionen das Canvas und rufen letzlich immer nur die bereits oben definierten Methoden auf.

Das Zwischen-Ergbenis unseres Canvasmap-Plugins.

Und was habe ich davon?

Dies werden sich nun einige fragen. Als 1. haben wir objekt-oriientierten Code hinterlassen. Dies hat in verschiedener Hinsicht Vorteile. So haben wir beispielsweise alle Zustände und Eigenschaften aus der DOM-Map sauber in unserer Objektinstanz zwischengespeichert. Die Eigenschaft areas zeigt beispielsweise nur auf die Areas unserer einen Imagemap, selbst wenn wir mehrere Imagemaps auf unserer Seite haben und diese mit unserem Plugin behandeln.

Einige jQuery-Plugin Autoren werden nun aufschreien und konstatieren, dass sie den selben Effekt mit der herkömmlichen Architektur ebenfalls hin bekommen. Dies stimmt tatsächlich. Je nachdem, ob man Funktion oder Variable außerhalb oder innerhalb der this.each-Funktion definiert, erreicht man einen derartigen „Speicherzustand“ entweder für alle DOM-Elemente des aktuellen jQuery-Objekts innerhalb seines Plugins oder für jedes einzelne DOM-Element innerhalb des Plugins.

Das hängt stark mit der Funktionsweise von Closures zusammen. Das Problem hierbei ist, dass eine Menge hilfreicher Funktionen und Variablen privat sind und schwer von außen zugänglich gemacht werden können.

Stellt euch beispielsweise mal vor euer Kunde kommt auf euch zu und will nun eine weitere Funktionalität implementiert haben. Ausserhalb der Imagemap soll eine Legende mit Kontrollelementen erscheinen. Sobald der User auf eines dieser Elemente klickt, soll ein bestimmter Stadtteil gehighlightet werden. Die gesamte Funktionalität habt ihr ja im Prinzip geschrieben.

Der Autor, welcher die herkömmliche Schreibweise von jQuery-Plugins benutzt hat, wird nun erklären, dass er für das Hinzufügen der neuen Funktionalität eine halbe Stunde benötigt. Daneben wird ihm – vorausgesetzt er steht auf sauber organisierten Code – der Kopf rauchen wie er diese Funktionalität sauber und flexibel in sein Plugin und möglichst als Option integriert.

Der Widget-Factory-Autor wird selbstverständlich selbiges behaupten, braucht aber beispielsweise keine Zeit sich zu überlegen wie er seinen Code nun neu organisieren muss und hat noch genügend Zeit für eine oder zwei Kippen :-) .

Zugriff von außen

Die UI-Factory Methode fügt unserem jeweiligen DOM-Element unsere Objektinstanz – ähnlich einem DOM-Expando (nur Memory Leak sicher) – hinzu. Wir haben daher auf unsere gesamte Objektinstanz mit folgendem Ausdruck Zugriff.

var instance = $.data($('#map')[0], 'canvasmap');

In der Regel werden wir dies nicht benötigen, da die Factory uns eine sehr schöne Möglichkeit bietet einzelne Methoden von aussen zu erreichen. Hierbei ruft man das normale jQuery-Plugin auf und übergibt als 1. Parameter den Namen der Instanzmethode. Alle weiteren Parameter werden ebenfalls als Parameter an die Methode weitergegeben:

$('#map').canvasmap('drawArea', $('#spandau'), {
	bg: 'rgba(200,100,100,0.5)',
	borderWidth: 1,
	borderColor: 'rgba(0,0,0,0.5)'
});

Dieser Code sagt also nichts anderes als: Mal die area mit der id ’spandau’ mit folgenden Optionen aus.

Einfacher geht es nicht oder? Demo für den Zugriff von außen.

Ausblick auf Teil II des Tutorials

Neben der Widget-Factory hat sich das UI-Team auch Gedanken darüber gemacht, wie die „Aussenwelt“ über entscheidende Zustandsänderungen innerhalb des Widgets informiert werden und wie man ein Widget modularisieren kann, um beispielsweise den Widget-Core klein zu halten oder um Dritten die Möglichkeit zu geben die Funktionalität zu erweitern/abzuändern, ohne dass eine Zeile des Original-Codes verändert werden muss. Wie man sein eigenes Widget derartig „pimpen“ kann soll der zweite Teil zeigen.

Written June 8, 2008 by
protofunc