Wer wissen möchte, wohin die Nutzer in der eigenen Anwendung so klicken (z.B. auf Dinge, von denen man selbst nicht annehmen würde, das Nutzer darauf klicken), der muss jeden Mausklick des Nutzers aufzeichnen. Aus diesen Daten kann man dann ermitteln, wohin die Nutzer ihren Mauszeiger so wandern lassen. Dafür gibt es bereits Opensourcelösungen, die meist auf PHP basieren. Unter dem Suchbergriff Heatmap wird man auch bei Google fündig. Da sich alles in diesem Blog um Java und dann noch um Wicket dreht, lag es natürlich nahe, zu prüfen, ob und wie man diese Anforderung mit Wicket realisieren kann. Dieser Beitrag wäre viel kürzer, wenn man die Frage nach dem "ob" mit nein beantworten müsste. Kommen wir also zum "wie?".
Für die Umsetzung habe ich mir anfänglich einiges aus dem Beispielen aus folgendem Blogbeitrag entlehnt. Im Laufe der Zeit ist zwar davon nicht mehr viel zu sehen, das Grundprinzip ist aber das Gleiche geblieben.
Folgende Fragestellung stand am Anfang dieses Versuchs: Wie kann man mit Wicket Werte in einem AjaxRequest übergeben (z.B. die Mausposition). Die erste Idee bestand darin, ein unsichtbares Formular zu erstellen, in das man die Werte per Javascript einfügt und dieses Formular per Ajax abschickt. Das hat auch funktioniert, war aber irgendwie auch ein wenig zu aufwendig. Nach einer Reihe missglückter Versuche, den richtigen Ansatz zu finden, stellte ich diese Frage in der Wicket-Mailingliste und bekam den entscheidenen Tipp von Ernesto Reinaldo Barreiro. Es gab einen Vortrag der London Wicket User Group, der dieses Problem löste. Mit dieser Vorarbeit begann ich das Thema umzusetzen. Dabei hat sich die eigene Implementierung vom Original entfernt. Hier nun Schritt für Schritt der vollständige Code:
AbstractParameterizedDefaultAjaxBehavior
package de.wicketpraxis.web.blog.pages.questions.ajax.parameter;
import java.util.HashMap;
import java.util.Map;
import org.apache.wicket.Request;
import org.apache.wicket.RequestCycle;
import org.apache.wicket.ResourceReference;
import org.apache.wicket.ajax.AbstractDefaultAjaxBehavior;
import org.apache.wicket.ajax.AjaxRequestTarget;
import org.apache.wicket.markup.html.IHeaderResponse;
import org.apache.wicket.util.time.Duration;
public abstract class AbstractParameterizedDefaultAjaxBehavior extends AbstractDefaultAjaxBehavior
{
static int sec=0;
private Duration _throttleDelay;
@Override
public void renderHead(IHeaderResponse response)
{
super.renderHead(response);
response.renderJavascriptReference(new ResourceReference(AbstractParameterizedDefaultAjaxBehavior.class,"AbstractParameterizedDefaultAjaxBehavior.js"));
}
@Override
protected void respond(AjaxRequestTarget target)
{
Request request = RequestCycle.get().getRequest();
Map<String,Object> map=new HashMap<String, Object>();
Parameter<?>[] parameter = getParameter();
for (Parameter<?> p : parameter)
{
String svalue = request.getParameter(p.getName());
if (svalue!=null)
{
Object value=getComponent().getConverter(p.getType()).convertToObject(svalue, getComponent().getLocale());
map.put(p.getName(), value);
}
}
respond(target, new ParameterMap(map));
}
@Override
public CharSequence getCallbackUrl(boolean onlyTargetActivePage)
{
StringBuilder sb=new StringBuilder();
sb.append(super.getCallbackUrl(onlyTargetActivePage));
Parameter<?>[] parameter = getParameter();
for (Parameter<?> p : parameter)
{
sb.append("&").append(p.getName()).append("='+").append(p.getJavascript()).append("+'");
}
return sb.toString();
}
@Override
protected final CharSequence getCallbackScript()
{
if (_throttleDelay!=null)
{
return throttleScript(super.getCallbackScript(),"thw"+(sec++),_throttleDelay);
}
return super.getCallbackScript();
}
protected static class Parameter<T>
{
String _name;
String _javascript;
Class<T> _type;
protected Parameter(String name,Class<T> type,String javascript)
{
_name=name;
_type=type;
_javascript=javascript;
}
protected String getName()
{
return _name;
}
protected String getJavascript()
{
return _javascript;
}
protected Class<T> getType()
{
return _type;
}
}
protected static <T> Parameter<T> of(String name,Class<T> type,String javascript)
{
return new Parameter<T>(name, type, javascript);
}
protected static class ParameterMap
{
Map<String, Object> _map;
protected ParameterMap(Map<String, Object> map)
{
_map=map;
}
public <T> T getValue(Parameter<T> parameter)
{
return (T) _map.get(parameter.getName());
}
}
public final AbstractParameterizedDefaultAjaxBehavior setThrottleDelay(Duration throttleDelay)
{
_throttleDelay=throttleDelay;
return this;
}
protected abstract void respond(AjaxRequestTarget target,ParameterMap parameterMap);
protected abstract Parameter<?>[] getParameter();
}
Die drei wichtigsten Bestandteile dieser Klasse sind folgende: Die Klasse Parameter definiert den Namen, den Typ, und das Javascript, das für das Ermitteln des Wertes im Browser aufgerufen muss. Die Methode respond(AjaxRequestTarget) liest die Werte aus dem Request aus, konvertiert diese in den gewünschen Typ und ruf damit eine zu überschreibende Methode auf. Die Methode getCallbackUrl() liefert das Javascript-Fragment für die Url, die dann per Ajax aufgerufen wird.
Um die Position des Mauszeigers ermitteln zu können, muss man sich mit einer eigenen Funktion für so einen Event registrieren. Damit mehr als eine Funktion auf so einen Event reagieren kann, sollte man die Funktion, die davor registriert war, ebenfalls aufrufen. Um für diese Problematik, die vermutlich in allen abgeleiteten Klassen vorhanden ist, besser lösen zu können, binden wir automatisch eine hilfreiche Javascript-Klasse ein.
Callback = {
create: function(oldCallback,newCallback)
{
return function(a,b,c,d,e,f)
{
if (oldCallback)
{
oldCallback(a,b,c,d,e,f);
}
newCallback(a,b,c,d,e,f);
}
},
};
Der Code sieht etwas merkwürdig aus. Das liegt an folgenden Gründen: zum einen stehe ich mit Javascript immer noch auf Kriegsfuß (mit Wicket kann man den Javascript-Teil sehr schön verstecken) und habe daher keine geeignete Lösung gefunden, mit der es mir möglich war, alle Argumente des Funktionsaufrufs an die zwei Funktionen weiterzureichen. Da es aber Javascript egal ist, mit wie vielen Parametern man eine Funktion aufruft, werden auf diese Weise bis zu 6 Übergabeparameter weitergereicht. Für Hinweise an dieser Stelle bin ich extrem dankbar.
WicketWindowJavascript
Für vieles bringt Wicket bereits fertige Implementierungen mit. Allerdings verstecken sich diese manchmal an unauffälligen Stellen. Für das Ermitteln des sichtbaren Bereichs fand ich die nötigen Javascript-Funktionen bei der ModelWindow-Klasse. Da ich nicht das vollständige ModalWindow-Javascript einbinden wollte, habe ich diese allgemeinen Funktionen in eine eigene Resource verpackt.
package de.wicketpraxis.web.blog.pages.questions.ajax.parameter;
import org.apache.wicket.ResourceReference;
public class WicketWindowJavascript
{
private WicketWindowJavascript()
{
}
public static final ResourceReference RESOURCE=new ResourceReference(WicketWindowJavascript.class,"WicketWindowJavascript.js");
}
// aus der Datei modal.js zur Klasse ModalWindow
if (typeof(Wicket.Window) == "undefined") {
Wicket.Window = { };
}
/**
* Returns the height of visible area.
*/
Wicket.Window.getViewportHeight = function() {
if (window.innerHeight != window.undefined)
return window.innerHeight;
if (document.compatMode == 'CSS1Compat')
return document.documentElement.clientHeight;
if (document.body)
return document.body.clientHeight;
return window.undefined;
}
/**
* Returns the width of visible area.
*/
Wicket.Window.getViewportWidth = function() {
if (window.innerWidth != window.undefined)
return window.innerWidth;
if (document.compatMode == 'CSS1Compat')
return document.documentElement.clientWidth;
if (document.body)
return document.body.clientWidth;
return window.undefined;
}
/**
* Returns the horizontal scroll offset
*/
Wicket.Window.getScrollX = function() {
var iebody = (document.compatMode && document.compatMode != "BackCompat") ? document.documentElement : document.body
return document.all? iebody.scrollLeft : pageXOffset
}
/**
* Returns the vertical scroll offset
*/
Wicket.Window.getScrollY = function() {
var iebody = (document.compatMode && document.compatMode != "BackCompat") ? document.documentElement : document.body
return document.all? iebody.scrollTop : pageYOffset
}
/**
* Returns element offset
*/
Wicket.Window.getXYOffset = function(obj)
{
var curleft = 0;
var curtop = 0;
if (obj.offsetParent)
{
while (obj.offsetParent)
{
curleft += obj.offsetLeft;
curtop += obj.offsetTop;
obj = obj.offsetParent;
}
}
else
{
if (obj.x)
{
curleft += obj.x;
}
if (obj.y)
{
curtop += obj.y;
}
}
if (Wicket.Browser.isIE())
{
bodyElement=document.getElementsByTagName('body')[0];
// In IE there's a default margin in the page body. If margin's not defined,
// use defaults
var marginLeftExplorer = parseInt(bodyElement.style.marginLeft);
var marginTopExplorer = parseInt(bodyElement.style.marginTop);
/* assume default 10px/15px margin in explorer */
if (isNaN(marginLeftExplorer)) {marginLeftExplorer=10;}
if (isNaN(marginTopExplorer)) {marginTopExplorer=15;}
curleft=curleft+marginLeftExplorer;
curtop=curtop+marginTopExplorer;
}
return [curleft,curtop];
}
Hinzugefügt habe ich nur die Methode Wicket.Window.getXYOffset(), um den Offset für ein bestimmtes oder das erste Kindelement der Seite ermitteln zu können. Eine Klasse, die diese Methoden benötigt, muss die Resource entsprechend einbinden.
WindowResizeBehavior
Um die Heatmap in der richtigen Größe zeichnen zu können, benötigen wir Informationen über die Dimensionen des sichtbaren Bereichs. Für window.onresize wird ein neuer Callback registriert. Die Werte können über die ensprechend Wicket.Window-Funktionen ermittelt werden. Daher wird nicht nur der Callback registiert, sondern die Funktion nach dem Laden der Seite direkt aufgerufen. Auf diese Weise bekommen wir die Informationen über den sichtbaren Bereich nicht erst, wenn der Nutzer die Fenstergröße verändert.
package de.wicketpraxis.web.blog.pages.questions.ajax.parameter;
import org.apache.wicket.ResourceReference;
import org.apache.wicket.ajax.AjaxRequestTarget;
import org.apache.wicket.markup.html.IHeaderResponse;
import org.apache.wicket.util.time.Duration;
public abstract class WindowResizeBehavior extends AbstractParameterizedDefaultAjaxBehavior
{
static final Parameter<Integer> WIDTH=of("width", Integer.class, "Wicket.Window.getViewportWidth()");
static final Parameter<Integer> HEIGHT=of("height", Integer.class, "Wicket.Window.getViewportHeight()");
@Override
public void renderHead(IHeaderResponse response)
{
super.renderHead(response);
response.renderJavascriptReference(WicketWindowJavascript.RESOURCE);
response.renderOnDomReadyJavascript(getJavascript());
response.renderOnDomReadyJavascript(getCallbackScript().toString());
}
protected final String getJavascript()
{
return "window.onresize = Callback.create(window.onresize,function () {"+getCallbackScript()+"});";
}
@Override
protected final Parameter<?>[] getParameter()
{
return new Parameter<?>[]{ WIDTH,HEIGHT};
}
@Override
protected void respond(AjaxRequestTarget target, ParameterMap parameterMap)
{
onResize(target, parameterMap.getValue(WIDTH), parameterMap.getValue(HEIGHT));
}
protected abstract void onResize(AjaxRequestTarget target, int width, int height);
}
ElementOffsetBehavior
Die Bestimmung der richtigen Mauskoordinaten gestaltet sich insofern schwierig, als das nicht jede Webseite bei jeder Fenstergröße gleich aussieht. Damit die Koordinaten bei der Auswertung verwendbar bleiben, berechnen wir die Position in Relation zu einem Element, dass den Rahmen der Seite darstellen sollte (z.B. das erste div-Tag innerhalb des body-Tags, dass auf eine feste Breite gesetzt wurde und im sichtbaren Bereich zentriert dargestellt wird).
package de.wicketpraxis.web.blog.pages.questions.ajax.parameter;
import org.apache.wicket.ResourceReference;
import org.apache.wicket.ajax.AjaxRequestTarget;
import org.apache.wicket.markup.html.IHeaderResponse;
import de.wicketpraxis.web.blog.pages.questions.ajax.parameter.AbstractParameterizedDefaultAjaxBehavior.Parameter;
public abstract class ElementOffsetBehavior extends AbstractParameterizedDefaultAjaxBehavior
{
static final Parameter<Integer> X_OFFSET=of("xOffset", Integer.class, "xOffset");
static final Parameter<Integer> Y_OFFSET=of("yOffset", Integer.class, "yOffset");
private String _contentId;
@Override
public void renderHead(IHeaderResponse response)
{
super.renderHead(response);
response.renderJavascriptReference(WicketWindowJavascript.RESOURCE);
response.renderJavascriptReference(new ResourceReference(ElementOffsetBehavior.class,"ElementOffsetBehavior.js"));
response.renderOnDomReadyJavascript(getJavascript());
}
public ElementOffsetBehavior()
{
}
public ElementOffsetBehavior(String contentId)
{
_contentId=contentId;
}
protected final String getJavascript()
{
if (_contentId!=null) return "ElementOffsetBehavoir.init(function (xOffset,yOffset) {"+getCallbackScript()+"},'"+_contentId+"');";
return "ElementOffsetBehavoir.init(function (xOffset,yOffset) {"+getCallbackScript()+"});";
}
@Override
protected final Parameter<?>[] getParameter()
{
return new Parameter<?>[]{ X_OFFSET,Y_OFFSET};
}
@Override
protected void respond(AjaxRequestTarget target, ParameterMap parameterMap)
{
onOffset(target, parameterMap.getValue(X_OFFSET), parameterMap.getValue(Y_OFFSET));
}
protected abstract void onOffset(AjaxRequestTarget target, int xOffset, int yOffset);
}
Im Gegensatz zum letzten Behavior sind wir in diesem Fall auf etwas mehr Javascript angewiesen.
ElementOffsetBehavoir =
{
init : function(callback, contentId)
{
function Listener(callback, contentId)
{
this.xOffset = 0;
this.yOffset = 0;
this.firstElement = null;
// function(xOffset,yOffset)
this.onOffsetChanged = callback;
this.updateOffsets = function()
{
var offsets = Wicket.Window.getXYOffset(this.firstElement);
this.xOffset = offsets[0];
this.yOffset = offsets[1];
this.onOffsetChanged(this.xOffset, this.yOffset);
};
var bodyElement = document.getElementsByTagName('body')[0];
this.firstElement = bodyElement.childNodes[1];
if (contentId != null)
{
this.firstElement = document.getElementById(contentId);
}
}
var listener = new Listener(callback, contentId);
listener.updateOffsets();
window.onresize = Callback.create(window.onresize, function()
{
listener.updateOffsets();
});
}
}
Der Listener wird über die init-Methode initialisiert und wird ebenfalls aufgerufen, wenn die Fenstergröße verändert wurde. Außerdem wird die updateOffsets()-Methode bereits bei der Initialisierung aufgerufen. Somit ist auch in diesem Fall der Offset eines Elements bekannt.
PageMouseClickBehavior
Das letzte Behavior ist gleichzeitig das aufwendigste. In diesem Fall muss nicht nur die Position des Klicks ermittelt werden, sondern gleichzeitig in Relation zu einem Element gebracht werden. Auf diese Weise kommen die Koordinaten schon passend an und können z.B. auch negative Werte annehmen.
package de.wicketpraxis.web.blog.pages.questions.ajax.parameter;
import org.apache.wicket.Request;
import org.apache.wicket.ResourceReference;
import org.apache.wicket.ajax.AjaxRequestTarget;
import org.apache.wicket.markup.html.IHeaderResponse;
public abstract class PageMouseClickBehavior extends AbstractParameterizedDefaultAjaxBehavior
{
static final Parameter<Integer> MOUSE_X=of("x", Integer.class, "x");
static final Parameter<Integer> MOUSE_Y=of("y", Integer.class, "y");
String _contentId;
public PageMouseClickBehavior()
{
}
public PageMouseClickBehavior(String contentId)
{
_contentId=contentId;
}
@Override
public void renderHead(IHeaderResponse response)
{
super.renderHead(response);
response.renderJavascriptReference(WicketWindowJavascript.RESOURCE);
response.renderJavascriptReference(new ResourceReference(PageMouseClickBehavior.class,"PageMouseClickBehavior.js"));
response.renderOnDomReadyJavascript(getJavascript());
}
protected String getJavascript()
{
if (_contentId!=null) return "PageMouseClickBehavoir.init(function (x,y,xOffset,yOffset) {"+getCallbackScript()+"},'"+_contentId+"');";
return "PageMouseClickBehavoir.init(function (x,y,xOffset,yOffset) {"+getCallbackScript()+"});";
}
@Override
protected Parameter<?>[] getParameter()
{
return new Parameter<?>[] { MOUSE_X, MOUSE_Y };
}
@Override
protected void respond(AjaxRequestTarget target, ParameterMap map)
{
onClick(target, map.getValue(MOUSE_X), map.getValue(MOUSE_Y));
}
protected abstract void onClick(AjaxRequestTarget target, int x, int y);
}
Das dazugehörige Javascript kümmert sich um die Verarbeitung:
PageMouseClickBehavoir =
{
init: function(callback,contentId)
{
function Listener(callback,contentId)
{
this.xOffset=0;
this.yOffset=0;
this.firstElement=null;
// function(x,y)
this.onMouseEvent=callback;
this.mouseEvent= function(e)
{
tempX=0;
tempY=0;
if (Wicket.Browser.isIE() || Wicket.Browser.isGecko())
{
tempX = e.clientX + Wicket.Window.getScrollX();
tempY = e.clientY + Wicket.Window.getScrollY();
}
else
{
tempX = e.pageX
tempY = e.pageY
}
tempX-=this.xOffset;
tempY-=this.yOffset;
this.onMouseEvent(tempX,tempY);
return true;
};
this.updateOffsets=function()
{
var offsets=Wicket.Window.getXYOffset(this.firstElement);
this.xOffset=offsets[0];
this.yOffset=offsets[1];
};
var bodyElement=document.getElementsByTagName('body')[0];
this.firstElement=bodyElement.childNodes[1];
if (contentId!=null)
{
this.firstElement=document.getElementById(contentId);
}
this.updateOffsets();
}
var listener=new Listener(callback,contentId);
document.onmousedown=Callback.create(document.onmousedown,function(e)
{
listener.mouseEvent(e)
});
window.onresize=Callback.create(window.onresize,function()
{
listener.updateOffsets();
});
},
}
Sobald der Nutzer klickt (auch wenn es eigentlich nichts klickbares gibt), wird eine Request an die Anwendung gesendet. Diese Behavior können wir nun in die Anwendung einbauen, um die Mausklicks der Nutzer aufzuzeichnen.
Die Anwendung
In unserer Beispielanwendung werfen wir alles in einen Topf. Wir zeichnen alle Mausklicks auf und aktualisieren dann die Heatmap.
package de.wicketpraxis.web.blog.pages.questions.ajax.parameter;
import java.awt.AlphaComposite;
import java.awt.BasicStroke;
import java.awt.Color;
import java.awt.Composite;
import java.awt.Graphics2D;
import java.awt.RenderingHints;
import java.io.Serializable;
import java.util.ArrayList;
import java.util.List;
import org.apache.wicket.ajax.AjaxRequestTarget;
import org.apache.wicket.markup.html.WebMarkupContainer;
import org.apache.wicket.markup.html.WebPage;
import org.apache.wicket.markup.html.image.Image;
import org.apache.wicket.markup.html.image.NonCachingImage;
import org.apache.wicket.markup.html.image.resource.DynamicImageResource;
import org.apache.wicket.markup.html.image.resource.RenderedDynamicImageResource;
import org.apache.wicket.markup.html.panel.FeedbackPanel;
import org.apache.wicket.protocol.http.WebResponse;
import org.apache.wicket.util.time.Duration;
public class HeatMapPage extends WebPage
{
List<Pos> _points=new ArrayList<Pos>();
int _xOffset=0;
int _yOffset=0;
public HeatMapPage()
{
final FeedbackPanel feedbackPanel = new FeedbackPanel("feedback");
feedbackPanel.setOutputMarkupId(true);
add(feedbackPanel);
final WebMarkupContainer box=new WebMarkupContainer("box");
final ClickMap imageResource = new ClickMap(100, 100);
final Image image = new NonCachingImage("map",imageResource);
box.setOutputMarkupId(true);
box.add(image);
add(box);
add(new ElementOffsetBehavior("#content")
{
@Override
protected void onOffset(AjaxRequestTarget target, int xOffset, int yOffset)
{
info("Offset: "+xOffset+","+yOffset);
_xOffset=xOffset;
_yOffset=yOffset;
imageResource.invalidate();
target.addComponent(feedbackPanel);
target.addComponent(box);
}
}.setThrottleDelay(Duration.milliseconds(250)));
add(new PageMouseClickBehavior("#content")
{
@Override
protected void onClick(AjaxRequestTarget target, int x, int y)
{
info("Clicked: "+x+","+y);
_points.add(new Pos(x,y));
imageResource.invalidate();
target.addComponent(box);
target.addComponent(feedbackPanel);
}
}.setThrottleDelay(Duration.milliseconds(50)));
add(new WindowResizeBehavior()
{
@Override
protected void onResize(AjaxRequestTarget target, int width, int height)
{
info("Size changed: "+width+","+height);
imageResource.setWidth(width);
imageResource.setHeight(height);
imageResource.invalidate();
target.addComponent(feedbackPanel);
target.addComponent(box);
}
}.setThrottleDelay(Duration.milliseconds(250)));
}
class ClickMap extends RenderedDynamicImageResource
{
public ClickMap(int width, int height)
{
super(width, height,"jpg");
setCacheable(false);
}
@Override
protected boolean render(Graphics2D graphics)
{
graphics.setBackground(new Color(255,255,255));
graphics.setColor(new Color(0,0,0,50));
graphics.clearRect(0, 0, getWidth(), getHeight());
graphics.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
for (Pos p : _points)
{
graphics.fillArc(_xOffset+p.getX()-5, _yOffset+p.getY()-5, 9, 9, 0, 360);
}
return true;
}
}
static class Pos implements Serializable
{
int _x;
int _y;
public Pos(int x, int y)
{
super();
_x = x;
_y = y;
}
public int getX()
{
return _x;
}
public int getY()
{
return _y;
}
}
}
Im Markup müssen wir dann nur das Image hinter der Seite platzieren und schon stimmen Mausklick und Heatmap überein.
<html>
<head>
<title>Heatmap Page</title>
</head>
<body>
<div id="#content" style="width:800px; margin:auto; border:1px solid #888;">
<div wicket:id="feedback"></div>
</div>
<div wicket:id="box" style="z-index: -1; position: absolute; top: 0px;left: 0px;">
<img wicket:id="map">
</div>
</body>
</html>
In diesem Beispiel haben ich den Rahmen auf eine Breite von 800 Pixeln gesetzt und zentriert. Trotzdem werden die Mausklicks immer korrekt angezeigt.
Wie man sehen kann, ist die Interaktion von Wicket mit Ereignissen, die durch Javascript ausgelöst werden, ohne weiteres möglich. Dabei können diese Komponenten ohne weiteres in beliebigen Anwendungen benutzt werden, ohne das sich ein anderer Entwickler mit den Implementierungsdetails beschäftigen muss. Allerdings hätte ich mir gewünscht, dass die Parameterisierung von Ajax-Aufrufen bereits in den Kern von Wicket integriert wäre.
Viel Spass mit dem ausprobieren:)