Index: /tags/stacks/webmap-0.1.0/CMakeLists.txt
===================================================================
--- /tags/stacks/webmap-0.1.0/CMakeLists.txt	(revision 34)
+++ /tags/stacks/webmap-0.1.0/CMakeLists.txt	(revision 34)
@@ -0,0 +1,17 @@
+cmake_minimum_required(VERSION 2.4.6)
+include($ENV{ROS_ROOT}/core/rosbuild/rosbuild.cmake)
+
+# Append to CPACK_SOURCE_IGNORE_FILES a semicolon-separated list of
+# directories (or patterns, but directories should suffice) that should
+# be excluded from the distro.  This is not the place to put things that
+# should be ignored everywhere, like "build" directories; that happens in
+# rosbuild/rosbuild.cmake.  Here should be listed packages that aren't
+# ready for inclusion in a distro.
+#
+# This list is combined with the list in rosbuild/rosbuild.cmake.  Note
+# that CMake 2.6 may be required to ensure that the two lists are combined
+# properly.  CMake 2.4 seems to have unpredictable scoping rules for such
+# variables.
+#list(APPEND CPACK_SOURCE_IGNORE_FILES /core/experimental)
+
+rosbuild_make_distribution(0.1.0)
Index: /tags/stacks/webmap-0.1.0/html/index.html
===================================================================
--- /tags/stacks/webmap-0.1.0/html/index.html	(revision 34)
+++ /tags/stacks/webmap-0.1.0/html/index.html	(revision 34)
@@ -0,0 +1,24 @@
+<!DOCTYPE html>
+<html xmlns="http://www.w3.org/1999/xhtml">
+<head>
+	<meta http-equiv="Content-Type" content="application/xhtml+xml;charset=utf-8" />
+	<title>WebMap</title>
+	
+	<script type="text/javascript" src="js/ros.js"></script>
+	<script type="text/javascript" src="js/webmap/base.js"></script>
+	<script type="text/javascript" src="js/webmap/map.js"></script>
+	<script type="text/javascript" src="js/webmap/robot.js"></script>
+	<script type="text/javascript" src="js/webmap/modules.js"></script>
+	<script type="text/javascript" src="js/application.js"></script>
+	<link rel="stylesheet" type="text/css" href="webmap.css" />
+</head>
+<body>
+	<h1>WebMap</h1>
+	
+	<p id="log" class="log"></p>
+
+	<p>Selected: <span id="selected"></span>.</p>
+	<div id="webmap_container">
+	</div>
+</body>
+</html>
Index: /tags/stacks/webmap-0.1.0/html/js/application.js
===================================================================
--- /tags/stacks/webmap-0.1.0/html/js/application.js	(revision 34)
+++ /tags/stacks/webmap-0.1.0/html/js/application.js	(revision 34)
@@ -0,0 +1,76 @@
+var log        = null;
+var container  = null;
+var connection = null;
+var map        = null;
+
+/// Create a circle model.
+function circleModel(radius) {
+	var svg    = document.createElementNS(webmap.svgns, "g");
+	var circle = document.createElementNS(webmap.svgns, "circle");
+	var line   = document.createElementNS(webmap.svgns, "line");
+	svg.appendChild(circle);
+	svg.appendChild(line);
+	
+	circle.setAttribute("cx", 0);
+	circle.setAttribute("cy", 0);
+	circle.setAttribute("r", radius);
+	
+	line.setAttribute("x1", 0);
+	line.setAttribute("y1", 0);
+	line.setAttribute("x2", radius);
+	line.setAttribute("y2", 0);
+	
+	return svg;
+}
+
+/// Example robot class for Stage robots.
+function StageRobot(name, index, connecion) {
+	
+	// Create SVG content.
+	var svg = circleModel(0.2);
+	
+	// Call parent constructor.
+	webmap.Robot.call(this, name, svg);
+	
+	// Add CSS classes.
+	this.svg.classList.add("stage");
+	this.svg.classList.add("stage_" + index);
+	
+	// Set up odometry, navigation and laser scanner.
+	var topic_base = "/robot_" + index;
+	this.setOdometryTopic(connection, topic_base + "/odom");
+	this.setTwistTopic(connection, topic_base + "/cmd_vel");
+	this.addModule("LaserScanner", connection, topic_base + "/base_scan");
+}
+
+webmap.extend(webmap.Robot, StageRobot);
+
+function init() {
+	log        = document.getElementById("log");
+	container  = document.getElementById("webmap_container");
+	
+	// Create the map.
+	map = new webmap.Map(container, 700, 500);
+	map.addModule("MouseViewControl", 1, 1.5);
+	map.addModule("ShowSelected", document.getElementById("selected"));
+	map.addModule("Teleop", 1, 1.5);
+	map.addImage("background.png", -29.35, -27, 58.7, 54.0);
+	map.scale(500 / 58.7, map.canvas_width * 0.5, map.canvas_height * 0.5);
+	
+	// Create the rosbridge connection.
+	connection = new ros.Bridge("ws://localhost:9090");
+	connection.onClose = function (e) {
+		log.textContent = "Connection closed.\n" + log.textContent;
+	};
+	connection.onError = function (e) {
+		log.textContent = "Error: " + e + ".\n" + log.textContent;
+	};
+	connection.onOpen = function (e) {
+		log.textContent = "Connection established.\n" + log.textContent;
+		// Add two Stage robots.
+		map.addRobot(new StageRobot("Robot 0", 0, connection));
+		map.addRobot(new StageRobot("Robot 1", 1, connection));
+	};
+}
+
+document.addEventListener("DOMContentLoaded", init, false);
Index: /tags/stacks/webmap-0.1.0/html/js/models.js
===================================================================
--- /tags/stacks/webmap-0.1.0/html/js/models.js	(revision 34)
+++ /tags/stacks/webmap-0.1.0/html/js/models.js	(revision 34)
@@ -0,0 +1,5 @@
+// Define default models. Feel free to add more models here or elsewhere.
+webmap.models.debug   = webmap.createImageModel("models/debug.svg",   1, 1);
+webmap.models.circle  = webmap.createImageModel("models/circle.svg",  1, 1);
+webmap.models.square  = webmap.createImageModel("models/square.svg",  1, 1);
+webmap.models.android = webmap.createImageModel("models/android.svg", 1, 1, 0.5, 1);
Index: /tags/stacks/webmap-0.1.0/html/js/ros.js
===================================================================
--- /tags/stacks/webmap-0.1.0/html/js/ros.js	(revision 34)
+++ /tags/stacks/webmap-0.1.0/html/js/ros.js	(revision 34)
@@ -0,0 +1,181 @@
+/* A simple javascript websockets client for the rosbridge v2.0 protocol. 
+ * 
+ * An AMD-compatible version of ros.js is provided as ros_amd.js
+ * 
+ * A backwards-compatible ros.js is provided as ros_compatible.js
+ * 
+ * Recommended practice is to use ros.js
+ * 
+ * **/
+
+var Bridge = function(url) {
+    // Initialize internal variables
+    this.service_handlers = {};
+    this.service_seed = 0;
+    this.subscription_handlers = {};
+    
+    // Ensure that JSON and WebSocket are available.  Thrown an exception if not
+    if (!WebSocket && MozWebSocket) {
+        WebSocket = MozWebSocket;
+    }
+    if (!WebSocket) {
+        throw "Browser does not support WebSockets";
+    }
+    if (!JSON) {
+        throw "Browser does not support JSON";
+    }
+    
+    // Create the connection
+    this.socket = new WebSocket(url);
+    
+    var self = this
+    this.socket.onmessage = function() {
+        self.receiveMessage.apply(self, arguments);
+        self.onMessage.apply(self, arguments);
+    }
+    this.socket.onerror = function() {
+        self.onError.apply(self, arguments);
+    }
+    this.socket.onopen = function() {
+        self.onOpen.apply(self, arguments);
+    }
+    this.socket.onclose = function() {
+        self.onClose.apply(self, arguments);
+    }
+}
+
+Bridge.prototype.send = function(op, id, msg) {
+    msg.op = op;
+    if (id != null) {
+        msg.id = id;
+    }
+    this.socket.send(JSON.stringify(msg));
+}
+
+Bridge.prototype.advertise = function(topic, type, /*optional*/ id) {
+    this.send("advertise", id, {topic: topic, type: type});
+}
+
+Bridge.prototype.unadvertise = function(topic, /*optional*/ id) {
+    this.send("unadvertise", id, {topic: topic});
+}
+
+Bridge.prototype.publish = function(topic, msg, /*optional*/ id) {
+    this.send("publish", id, {topic: topic, msg: msg});
+}
+
+Bridge.prototype.subscribe = function(callback, topic, /*optional*/ type, /*optional*/ throttle_rate, 
+      /*optional*/ queue_length, /*optional*/ fragment_size, /*optional*/ compression, /*optional*/ id) {
+    // Construct the message
+    msg = {topic: topic};
+    if (type != null) msg.type = type;
+    if (throttle_rate != null) msg.throttle_rate = throttle_rate;
+    if (queue_length != null) msg.queue_length = queue_length;
+    if (fragment_size != null) msg.fragment_size = fragment_size;
+    if (compression != null) msg.compression = compression;
+    
+    // Send the message
+    this.send("subscribe", id, msg);
+    
+    // Save the callback
+    if (this.subscription_handlers[topic] == null) {
+        this.subscription_handlers[topic] = {};
+    }
+    if (this.subscription_handlers[topic][id] == null) {
+        this.subscription_handlers[topic][id] = [];
+    }
+    this.subscription_handlers[topic][id].push(callback);
+}
+
+Bridge.prototype.unsubscribe = function(topic, /*optional*/ id) {
+    // Send the message
+    this.send("unsubscribe", id, {topic: topic});
+    
+    // Delete callbacks
+    if (this.subscription_handlers[topic] && this.subscription_handlers[topic][id]) {
+        delete this.subscription_handlers[topic][id];
+    }
+    if (id==null || Object.keys(this.subscription_handlers[topic]).length == 0) {
+        delete this.subscription_handlers[topic];
+    }
+}
+
+Bridge.prototype.callService = function(callback, service, /*optional*/ args,
+        /*optional*/ fragment_size, /*optional*/ compression, /*optional*/ id) {
+    // Construct the message
+    msg = {service: service};
+    if (args != null) msg.args = args;
+    if (fragment_size != null) msg.fragment_size = fragment_size;
+    if (compression != null) msg.compression = compression;
+    
+    // Generate an ID for service calls
+    if (id == null) {
+        id = this.service_seed;
+        this.service_seed++;
+    }
+    
+    // Send the message
+    this.send("call_service", id, msg);
+    
+    // Save the callback
+    if (this.service_handlers[service] == null) {
+        this.service_handlers[service] = {};
+    }
+    this.service_handlers[service][id] = callback;
+}
+
+Bridge.prototype.receiveMessage = function(event) {
+    msg = JSON.parse(event.data);
+    
+    switch(msg.op) {
+        case "publish": this.onPublish(msg); break;
+        case "service_response": this.onServiceResponse(msg); break;
+    }
+}
+
+Bridge.prototype.onOpen = function(event) {}
+Bridge.prototype.onClose = function(event) {}
+Bridge.prototype.onError = function(event) {}
+Bridge.prototype.onMessage = function(event) {}
+
+Bridge.prototype.onPublish = function(message) {
+    // Extract message details
+    topic = message.topic;
+    msg = message.msg;
+    
+    // Copy the callbacks - in case the callback modifies the subscription
+    var callbacks = [];
+    for (var id in this.subscription_handlers[topic]) {
+        callbacks = callbacks.concat(this.subscription_handlers[topic][id]);
+    }
+    
+    // Call all the callbacks
+    for (var i = 0; i < callbacks.length; i++) {
+        try {
+            callbacks[i](msg);
+        } catch (err) {
+            // Best we can do is print the error
+            console.error(err);
+        }
+    }
+}
+
+Bridge.prototype.onServiceResponse = function(response) {
+    // Extract message details
+    service = response.service;
+    values = response.values;
+    id = response.id;
+    
+    // Call the callback and remove it
+    if (this.service_handlers[service] && this.service_handlers[service][id]) {
+        callback = this.service_handlers[service][id];
+        delete this.service_handlers[service][id];
+        if (Object.keys(this.service_handlers[service]).length == 0) {
+            delete this.service_handlers[service];
+        }
+        callback(values);
+    }
+}
+
+var ros = ros || {};
+ros.Bridge = Bridge;
Index: /tags/stacks/webmap-0.1.0/html/js/webmap/base.js
===================================================================
--- /tags/stacks/webmap-0.1.0/html/js/webmap/base.js	(revision 34)
+++ /tags/stacks/webmap-0.1.0/html/js/webmap/base.js	(revision 34)
@@ -0,0 +1,131 @@
+var webmap = webmap || {};
+webmap.models  = {}; /// Namespace for models.
+webmap.modules = {}; /// Namespace for modules.
+
+/// XML namespace for SVG elements.
+webmap.svgns   = "http://www.w3.org/2000/svg";
+/// XML namespace for XLink attributes.
+webmap.xlinkns = "http://www.w3.org/1999/xlink";
+
+/// SVG root element for creating matrices.
+webmap.svg = document.createElementNS(webmap.svgns, "svg");
+
+
+/// Counter to generate locally unique ID's.
+webmap.id_counter = 1;
+
+/// Generate a locally unique ID.
+webmap.generateId = function() {
+	return "WebMap-" + (webmap.id_counter++);
+}
+
+/// Set up one class as a subclass of another.
+webmap.extend = function(base, sub) {
+	var dummy = function(){};
+	dummy.prototype = base.prototype;
+	sub.prototype = new dummy();
+	sub.prototype.constructor = sub;
+};
+
+
+/// Base for classes that can be extended with modules.
+webmap.Extendable = function(namespace) {
+	this.modules   = [];
+	this.module_ns = namespace;
+}
+
+/// Add a module.
+/**
+ * Pass in any additional module arguments after the name argument.
+ * The modules constructor will be called with the map as the first argument,
+ * followed by any additional arguments you specify here.
+ * 
+ * \param name The name of the modules.
+ * \param ...  The remaining arguments for the module constructor.
+ * \return A reference to the created module, which can be passed to removeModule().
+ */
+webmap.Extendable.prototype.addModule = function(name /*, ...*/) {
+	// Prepare the arguments for the module constructor.
+	var args = Array.prototype.slice.call(arguments, 1);
+	args.unshift(this);
+	var module = webmap.construct(this.module_ns[name], args);
+	this.modules.push(module);
+	return module;
+	
+}
+
+/// Remove a module.
+/**
+ * \param module The module to remove, as returned by the addModule method.
+ */
+webmap.Extendable.prototype.removeModule = function(module) {
+	var i = this.modules.indexOf(modules)
+	if (i != -1) {
+		if (module.destroy) module.destroy();
+		this.modules.splice(i, 1);
+	}
+}
+
+/// Notify all modules by calling a member function, if it exists.
+/**
+ * \param handler The name of the member function to call.
+ * \param ...     Any arguments to pass to the member function.
+ */
+webmap.Extendable.prototype.notifyModules = function(handler /*, ... */) {
+	var args = Array.prototype.slice.call(arguments, 1);
+	for (var i = 0; i < this.modules.length; ++i) {
+		var module = this.modules[i];
+		if (module[handler]) module[handler].apply(module, args);
+	}
+}
+
+
+
+/// Test if two vectors have the same X, Y and Z components. Any other difference is ignored.
+/**
+ * \param a Vector A.
+ * \param b Vector B.
+ * \return True if vector A and B have the same X, Y and Z components.
+ */
+webmap.vectorEqual = function(a, b) {
+	return a.x === b.x && a.y === b.y && a.z === b.z;
+}
+
+/// Test if two quaternions have the same X, Y, Z and W components. Any other difference is ignored.
+/**
+ * \param a Quaternion A.
+ * \param b Quaternion B.
+ * \return True if vector A and B have the same X, Y, Z and W componentss.
+ */
+webmap.quaternionEqual = function(a, b) {
+	return a.x === b.x && a.y === b.y && a.z === b.z && a.w === b.w;
+}
+
+/// Get the absolute rotation around the Z axis of an orientation quaternion.
+/**
+ * \param Q A unit quaternion representing an orientation.
+ * \return The angle in degrees.
+ * 
+ * This function applies the rotation to the vector (1, 0, 0),
+ * projects it on the XY plane and takes the angle with the X axis.
+ */
+webmap.quaternionZ = function(q) {
+	var x = q.w*q.w + q.x*q.x - q.y*q.y - q.z*q.z;
+	var y = 2*q.x*q.y + 2*q.w*q.z;
+	return Math.atan2(y, x) / Math.PI * 180.0;
+}
+
+
+/// Construct an object from a constructor and an array of arguments.
+/**
+ * \param constructor The constructor.
+ * \param args        The arguments as an array.
+ */
+webmap.construct = function(constructor, args) {
+	function F() {
+		return constructor.apply(this, args);
+	}
+	F.prototype = constructor.prototype;
+	return new F();
+}
+
Index: /tags/stacks/webmap-0.1.0/html/js/webmap/map.js
===================================================================
--- /tags/stacks/webmap-0.1.0/html/js/webmap/map.js	(revision 34)
+++ /tags/stacks/webmap-0.1.0/html/js/webmap/map.js	(revision 34)
@@ -0,0 +1,195 @@
+var webmap = webmap || {};
+webmap.modules.map = {}; /// Namespace for map modules.
+
+/// Create a new webmap.
+/**
+ * \param container     A reference to the container element.
+ * \param canvas_width  The width of the canvas.
+ * \param canvas_height The width of the canvas.
+ */
+webmap.Map = function(container, canvas_width, canvas_height) {
+	
+	// Call parent constructor.
+	webmap.Extendable.call(this, webmap.modules.map);
+	
+	// Copy parameters.
+	this.container  = container;
+	this.canvas_width  = canvas_width;
+	this.canvas_height = canvas_height;
+	
+	this.selected = null;
+	
+	// Create the SVG root element.
+	this.root = document.createElementNS(webmap.svgns, "svg");
+	this.root.setAttribute("version",      1.1);
+	this.root.setAttribute("baseProfile", "tiny");
+	this.root.setAttribute("class",        "webmap");
+	this.root.setAttribute("width",        this.canvas_width);
+	this.root.setAttribute("height",       this.canvas_height);
+	this.root = this.container.appendChild(this.root);
+	this.root.webmap = this;
+	
+	// Create a reference point outside of the transformed group.
+	this.ref_rect = document.createElementNS(webmap.svgns, "rect");
+	this.ref_rect.setAttribute("id", "ref_rect");
+	this.ref_rect.setAttribute("x", 0);
+	this.ref_rect.setAttribute("y", 0);
+	this.ref_rect.width  = this.root.width;
+	this.ref_rect.height = this.root.height;
+	this.ref_rect.setAttribute("fill",   "none");
+	this.ref_rect.setAttribute("stroke", "none");
+	this.ref_rect = this.root.appendChild(this.ref_rect);
+	
+	// Create the world group
+	this.world = document.createElementNS(webmap.svgns, "g");
+	this.world.setAttribute("id", "world");
+	this.world = this.root.appendChild(this.world);
+	
+	// Create initial view and virtual map transform.
+	this.world_tf = this.root.createSVGMatrix().translate(this.canvas_width * 0.5, this.canvas_height * 0.5).scaleNonUniform(1, -1);
+	this.applyView();
+	
+	this.mouseClickListener = this.mouseClick.bind(this);
+	this.root.addEventListener("click", this.mouseClickListener, false);
+}
+
+webmap.extend(webmap.Extendable, webmap.Map);
+
+/// Get the bounding rectangle of the SVG viewport.
+/**
+ * Returned coordinates are relative to the parent viewport.
+ */
+webmap.Map.prototype.getBoundingRect = function() {
+	return this.ref_rect.getBoundingClientRect();
+}
+
+/// Add an image to the map.
+/**
+ * \param iri         The IRI identifying the image.
+ * \param angle       The rotation of the image in degrees.
+ * \param x           The X coordinate of the bottom left corner of the image.
+ * \param y           The Y coordinate of the bottom left corner of the image.
+ * \param width       The virtual width of the image.
+ * \param height      The virtual width of the image.
+ * \return The created image.
+ */
+webmap.Map.prototype.addImage = function(iri, x, y, width, height) {
+	var img = document.createElementNS(webmap.svgns, "image");
+	img.setAttributeNS(webmap.xlinkns, "href", iri);
+	img.setAttribute("width",  width);
+	img.setAttribute("height", height);
+	img.setAttribute("preserveAspectRatio", "none");
+	img.setAttribute("viewbox", "defer");
+	img.setAttribute("x",      0);
+	img.setAttribute("y",      0);
+	
+	var tf = this.root.createSVGMatrix()
+		.translate(x, y)
+		.translate(width * 0.5, height * 0.5)
+		.scaleNonUniform(1, -1) // Flip the Y axis to correct for the right handed coordinate system.
+		.translate(width * -0.5, height * -0.5)
+	
+	img.transform.baseVal.initialize(img.transform.baseVal.createSVGTransformFromMatrix(tf));
+	this.world.appendChild(img);
+	return img;
+}
+
+/// Remove an image from the map.
+/**
+ * \param img The image to remove, as returned from addImage().
+ */
+webmap.Map.prototype.removeImage = function(img) {
+	if (img.parentNode === this.world) this.world.removeChild(img);
+}
+
+/// Add a robot to the map.
+/**
+ * \param robot  The robot to add.
+ */
+webmap.Map.prototype.addRobot = function(robot) {
+	if (robot.map && robot.map !== this) {
+		robot.map.removeRobot(robot);
+	} else if (robot.map === this) {
+		return;
+	}
+	
+	robot.map = this;
+	this.world.appendChild(robot.svg);
+	this.notifyModules("onAddRobot", robot);
+}
+
+/// Remove a robot from the map.
+/**
+ * \param robot The robot to remove.
+ * \return True if the robot was on the map and has been removed, false otherwise.
+ */
+webmap.Map.prototype.removeRobot = function(robot) {
+	if (!robot.map || robot.map !== this) return false;
+	
+	if (robot.svg.parentNode) robot.svg.parentNode.removeChild(robot.svg);
+	this.notifyModules("onRemoveRobot", robot);
+	return true;
+}
+
+/// Change the selected robot.
+/**
+ * \param robot The robot to select.
+ */
+webmap.Map.prototype.setSelected = function(robot) {
+	if (this.selected) this.selected.setSelected(false);
+	this.selected = robot;
+	if (this.selected) this.selected.setSelected(true);
+	
+	/// Notify all interested modules.
+	this.notifyModules("onSelect");
+}
+
+/// Apply world transforms.
+/**
+ * Changing the view allows you to zoom in/out on parts of the map,
+ * and to rotate the map.
+ */
+webmap.Map.prototype.applyView = function() {
+	this.world.transform.baseVal.initialize(this.world.transform.baseVal.createSVGTransformFromMatrix(this.world_tf));
+}
+
+/// Translate the world.
+/**
+ * \param x The X offset to translate by.
+ * \param y The Y offset to translate by.
+ */
+webmap.Map.prototype.translate = function(x, y) {
+	this.world_tf = this.root.createSVGMatrix().translate(x, y).multiply(this.world_tf);
+	this.applyView();
+}
+
+/// Scale the world around a viewport point.
+/**
+ * \param factor The factor to scale by.
+ * \param x The X coordinate of the viewport point to use as center.
+ * \param y The Y coordinate of the viewport point to use as center.
+ */
+webmap.Map.prototype.scale = function(factor, x, y) {
+	if (x == undefined) x = this.map.canvas_width  * 0.5;
+	if (y == undefined) y = this.map.canvas_height * 0.5;
+	this.world_tf = this.root.createSVGMatrix().translate(x - x * factor, y - y * factor).scale(factor).multiply(this.world_tf);
+	this.applyView();
+}
+
+/// Rotate the world around a given point.
+/**
+ * \param angle The angle to rotate by.
+ * \param x The X coordinate of the viewport point to use as center.
+ * \param y The Y coordinate of the viewport point to use as center.
+ */
+webmap.Map.prototype.rotate = function(angle, x, y) {
+	this.world_tf = this.root.createSVGMatrix().translate(x, y).rotate(angle).translate(-x, -y).multiply(this.world_tf);
+	this.applyView();
+}
+
+
+/// Handle mouse clicks.
+webmap.Map.prototype.mouseClick = function(event) {
+	// Clear the selection.
+	this.setSelected(null);
+}
Index: /tags/stacks/webmap-0.1.0/html/js/webmap/modules.js
===================================================================
--- /tags/stacks/webmap-0.1.0/html/js/webmap/modules.js	(revision 34)
+++ /tags/stacks/webmap-0.1.0/html/js/webmap/modules.js	(revision 34)
@@ -0,0 +1,365 @@
+/// Map modules to control the view using the mouse.
+/**
+ * \param map The map.
+ */
+webmap.modules.map.MouseViewControl = function(map) {
+	this.map        = map;
+	this.hide_click = false;
+	
+	this.mouseDownListener  = this.mouseDown.bind(this);
+	this.mouseUpListener    = this.mouseUp.bind(this);
+	this.mouseMoveListener  = this.mouseMove.bind(this);
+	this.mouseWheelListener = this.mouseWheel.bind(this);
+	this.mouseClickListener = this.mouseClick.bind(this);
+	
+	this.map.root.addEventListener("mousedown",      this.mouseDownListener,  false);
+	this.map.root.addEventListener("mouseup",        this.mouseUpListener,    false);
+	this.map.root.addEventListener("mouseleave",     this.mouseUpListener,    false);
+	this.map.root.addEventListener("wheel",          this.mouseWheelListener, false);
+	this.map.root.addEventListener("mousewheel",     this.mouseWheelListener, false);
+	this.map.root.addEventListener("DOMMouseScroll", this.mouseWheelListener, false);
+	this.map.root.addEventListener("click",          this.mouseClickListener, true);
+}
+
+/// Destroy the module and clean up.
+webmap.modules.map.MouseViewControl.prototype.destroy = function() {
+	this.map.root.removeEventListener("mousedown",      this.mouseDownListener,  false);
+	this.map.root.removeEventListener("mouseup",        this.mouseUpListener,    false);
+	this.map.root.removeEventListener("mousemove",      this.mouseMoveListener,  false);
+	this.map.root.removeEventListener("mouseleave",     this.mouseUpListener,    false);
+	this.map.root.removeEventListener("wheel",          this.mouseWheelListener, false);
+	this.map.root.removeEventListener("mousewheel",     this.mousewheelListener, false);
+	this.map.root.removeEventListener("DOMMouseScroll", this.mousewheelListener, false);
+	this.map.root.removeEventListener("click",          this.mouseClickListener, true);
+}
+
+/// Handle mouse down events.
+webmap.modules.map.MouseViewControl.prototype.mouseDown = function(event) {
+	// Normalize the event and stop the default action.
+	event = event || window.event;
+	event.preventDefault();
+	
+	// Remember the mouse coordinates.
+	this.mouse_x     = event.clientX;
+	this.mouse_y     = event.clientY;
+	this.mouse_moved = false;
+	this.map.root.addEventListener("mousemove",  this.mouseMoveListener, false);
+}
+
+/// Handle mouse move events.
+webmap.modules.map.MouseViewControl.prototype.mouseMove = function(event) {
+	// Skip the event if the mouse wasn't pressed on the map.
+	if (this.mouse_x === undefined) return;
+	
+	// Normalize the event and prevent default actions.
+	event = event || window.event;
+	event.preventDefault();
+	
+	// Scroll and remember the mouse coordinates.
+	var diff_x = (event.clientX - this.mouse_x);
+	var diff_y = (event.clientY - this.mouse_y);
+	this.map.translate(diff_x, diff_y);
+	this.mouse_x     = event.clientX;
+	this.mouse_y     = event.clientY;
+	this.mouse_moved = true;
+}
+
+/// Capture click events and shove them under the carpet.
+webmap.modules.map.MouseViewControl.prototype.mouseClick = function(event) {
+	if (this.hide_click) {
+		event = event || window.event;
+		event.preventDefault();
+		event.stopPropagation();
+		this.hide_click = false;
+	}
+}
+
+/// Handle mouse up events.
+webmap.modules.map.MouseViewControl.prototype.mouseUp = function(event) {
+	// Skip the event if the mouse wasn't pressed on the map.
+	if (this.mouse_x === undefined) return;
+	
+	// Normalize the event and prevent default actions.
+	event = event || window.event;
+	event.preventDefault();
+	
+	// Scroll and clear the mouse coordinates.
+	var diff_x = (event.clientX - this.mouse_x);
+	var diff_y = (event.clientY - this.mouse_y);
+	
+	// If the mouse has moved since going down, it's not a click but a drag.
+	this.hide_click = diff_x || diff_y || this.mouse_moved;
+	
+	this.map.translate(diff_x, diff_y);
+	this.mouse_x     = undefined;
+	this.mouse_y     = undefined;
+	this.mouse_moved = undefined;
+	this.map.root.removeEventListener("mousemove", this.mouseMoveListener, false);
+	
+}
+
+/// Handle mouse scroll events.
+webmap.modules.map.MouseViewControl.prototype.mouseWheel = function(event) {
+	event = event || window.event;
+	event.preventDefault();
+	
+	// Get the X and Y coordinates of the mouse within the SVG viewport.
+	var bounds = this.map.getBoundingRect();
+	var x = event.clientX - bounds.left;
+	var y = event.clientY - bounds.top;
+	
+	// Determine (or estimate) the amount of lines scrolled.
+	var lines = 0;
+	
+	// Modern wheel events.
+	if (event.deltaMode !== undefined && event.deltaY !== undefined) {
+		switch (event.deltaMode) {
+			case 0x00: lines = event.deltaY / 14; break; // About 14 pixels per line?
+			case 0x01: lines = event.deltaY;      break; // About exactly 1 line per line.
+			case 0x02: lines = event.deltaY * 10; break; // Lets just call a page 10 lines.
+		}
+	// Old Gecko DOMMouseScroll events.
+	} else if (event.detail) {
+		switch (event.detail) {
+			case  32768: lines =  10;          break; // Lets just call a page 10 lines.
+			case -32768: lines = -10;          break; // Lets just call a page 10 lines.
+			default:     lines = event.detail; break; // About exactly 1 line per line.
+		}
+	// Old mousewheel events.
+	} else if (event.wheelDelta) {
+		lines = event.wheelDelta / -40.0;
+	}
+	
+	// Handle the scroll.
+	if (event.shiftKey) {
+		this.map.rotate(5.0 * lines, this.map.canvas_width * 0.5, this.map.canvas_height * 0.5); // Rotate -5 degrees per line.
+	} else {
+		this.map.scale(Math.pow(1.15, -lines / 3.0), x, y); // Zoom 15% per 3 lines.
+	}
+}
+
+
+/// Map modules to show the name of the currently selected robot in a HTML element.
+/**
+ * The module works by replacing the text content of the given HTML element.
+ * 
+ * \param map         The map.
+ * \param element     The HTML element to display the name in.
+ * \param default_msg The message to show when there is no selected robot.
+ */
+webmap.modules.map.ShowSelected = function(map, element, default_msg) {
+	if (default_msg === undefined || default_msg === null) default_msg = "No selection";
+	
+	this.map                 = map;
+	this.default_msg         = default_msg;
+	this.element             = element;
+	this.onSelect();
+}
+
+/// Destroy the module and clean up.
+webmap.modules.map.ShowSelected.prototype.destroy = function() {
+	this.element.textContent = "";
+}
+
+/// Handle selection changes by updating the text content of the DOM element.
+webmap.modules.map.ShowSelected.prototype.onSelect = function() {
+	this.element.textContent =  this.map.selected ? this.map.selected.name : this.default_msg;
+}
+
+
+/// Module to allow keyboard control of robots.
+/**
+ * \param map            The map.
+ * \param speed          The forward speed.
+ * \param rotation_speed The rotational speed in rad/s.
+ */
+webmap.modules.map.Teleop = function(map, speed, rotation_speed) {
+	this.map   = map;
+	this.speed = speed;
+	this.rotation_speed = rotation_speed;
+	
+	// Initial keyboard state.
+	this.forward  = false;
+	this.backward = false;
+	this.left     = false;
+	this.right    = false;
+	
+	// Timeout ID so we can cancel it later.
+	this.timeout  = null;
+	
+	// Set up event listeners.
+	this.keydown_listener = this.keyEvent.bind(this, true);
+	this.keyup_listener   = this.keyEvent.bind(this, false);
+	this.blur_listener    = this.blur.bind(this);
+	document.body.addEventListener("keydown", this.keydown_listener, false);
+	document.body.addEventListener("keyup",   this.keyup_listener,   false);
+	document.addEventListener("blur", this.blur_listener, false);
+}
+
+/// Destroy the module and clean up.
+webmap.modules.map.Teleop.prototype.destroy = function() {
+	document.body.removeEventListener("keydown", this.keydown_listener, false);
+	document.body.removeEventListener("keyup",   this.keyup_listener,   false);
+	document.removeEventListener("blur", this.blur_listener, false);
+}
+
+/// Send the twist message based on the state of the keyboard.
+webmap.modules.map.Teleop.prototype.actuate = function() {
+	if (this.map && this.map.selected) {
+		var robot = this.map.selected;
+		var x = 0;
+		var rz = 0;
+		if (this.forward)  x  += this.speed;
+		if (this.backward) x  -= this.speed;
+		if (this.left)     rz += this.rotation_speed;
+		if (this.right)    rz -= this.rotation_speed;
+		if (x < 0) rz *= -1;
+		this.map.selected.twist(x, 0, 0, 0, 0, rz);
+		this.timeout = window.setTimeout(this.actuate.bind(this), 100);
+	}
+}
+
+/// When the document loses focus, make sure we forget all pressed keys.
+webmap.modules.map.Teleop.prototype.blur = function() {
+	// Reset keyboard state.
+	this.forward  = false;
+	this.backward = false;
+	this.left     = false;
+	this.right    = false;
+	
+	// Cancel any running timeout.
+	if (this.timeout !== null) {
+		window.clearTimeout(this.timeout);
+		this.timeout = null;
+	}
+}
+
+/// Handle key events on the map.
+/**
+ * \param down If true, the key has been pressed, otherwise it has been unpressed.
+ * \param event The event that triggered the listener.
+ */
+webmap.modules.map.Teleop.prototype.keyEvent = function(down, event) {
+	event = event || window.event;
+	
+	// Remember the state of interesting keys.
+	var interested = false;
+	switch (event.keyCode) {
+		case 87: // W (87) means forwards.
+			interested    = true;
+			this.forward  = down;
+			break;
+		case 83: // S (83) means backwards.
+			interested    = true;
+			this.backward = down;
+			break;
+		case 65: // A (65) means left.
+			interested    = true;
+			this.left     = down;
+			break;
+		case 68: // D (68) means right.
+			interested    = true;
+			this.right    = down;
+			break;
+	}
+	
+	// Start or stop the timeouts.
+	if (this.forward || this.backward || this.left || this.right) {
+		if (this.timeout === null) this.timeout = window.setTimeout(this.actuate.bind(this), 100);
+	} else if (this.timeout !== null) {
+		window.clearTimeout(this.timeout);
+		this.timeout = null;
+	}
+	
+	if (interested) event.preventDefault();
+}
+
+/*//////////////////
+// Robot modules
+// This section of the file contains modules for robots.
+//////////////////*/
+
+/// Create a new laser scanner sensor module.
+/**
+ * \param robot      The robot.
+ * \param connection The ROS connection.
+ * \param topic      The name of the LaserScan topic.
+ * \param x          The X offset relative from robot origin.
+ * \param y          The Y offset relative from the robot origin.
+ * \param angle      The rotation of the sensor in degrees.
+ * \param max_dots   (Optional) Maximum number of dots to show. Defaults to 50.
+ * \param throttle   (Optional) The minimum time in milliseconds between receiving two updates. Defaults to 300.
+ * 
+ * An angle of 0 degrees means the sensor is looking along the positive X axis of the robot.
+ */
+webmap.modules.robot.LaserScanner = function(robot, connection, topic, x, y, angle, max_dots, throttle) {
+	if (max_dots === undefined || max_dots === null) max_dots = 50;
+	if (throttle === undefined || throttle === null) throttle = 300;
+	
+	/// Locally unique ID.
+	this.id = webmap.generateId();
+	
+	this.robot        = robot;
+	this.connection   = connection;
+	this.topic        = topic;
+	this.svg          = document.createElementNS(webmap.svgns, "g");
+	this.svg.setAttribute("class", "laserscan");
+	this.dots         = [];
+	
+	this.x = x;
+	this.y = y;
+	this.angle = angle;
+	
+	// Create the dots to visualize the readings.
+	for (var i = 0; i < max_dots; ++i) {
+		var dot = document.createElementNS(webmap.svgns, "circle");
+		dot.setAttribute("cx",    0);
+		dot.setAttribute("cy",    0);
+		dot.setAttribute("r",     0.05);
+		dot.setAttribute("class", "dot");
+		dot.style.display = "none";
+		dot = this.svg.appendChild(dot);
+		this.dots.push(dot);
+	}
+	
+	// Add the SVG.
+	this.robot.svg.appendChild(this.svg);
+	
+	// Subsribe to the data topic.
+	this.connection.subscribe(this.handleData.bind(this), this.topic, null, throttle, null, null, null, this.id);
+}
+
+/// Destroy the module and clean up.
+webmap.modules.robot.LaserScanner.prototype.destroy = function() {
+	this.connection.unsubscribe(this.topic, this.id);
+	this.svg.parentNode.removeChild(this.svg);
+}
+
+/// Handle sensor data by updating the drawing.
+webmap.modules.robot.LaserScanner.prototype.handleData = function(msg) {
+	// Add a dot for all valid readings.
+	var count = Math.min(this.dots.length, msg.ranges.length);
+	
+	for (var i = 0; i < this.dots.length; ++i) {
+		// If a dot has no reading, make it invisible.
+		if (i >= count) {
+			this.dots[i].style.display = "none";
+			continue;
+		}
+		
+		var index = Math.round(i * msg.ranges.length / count);
+		var angle = ((msg.angle_min + index * msg.angle_increment) / Math.PI * 180 + 360) % 360;
+		var range = msg.ranges[index];
+		
+		// Don't render invalid readings.
+		if (range < msg.range_min || range > msg.range_max) {
+			this.dots[i].style.display = "none";
+			continue;
+		}
+		
+		// Place the dot.
+		var transform = webmap.svg.createSVGMatrix().rotate(angle).translate(range, 0);
+		this.dots[i].transform.baseVal.initialize(this.dots[i].transform.baseVal.createSVGTransformFromMatrix(transform));
+		this.dots[i].style.display = "inline";
+	}
+}
Index: /tags/stacks/webmap-0.1.0/html/js/webmap/robot.js
===================================================================
--- /tags/stacks/webmap-0.1.0/html/js/webmap/robot.js	(revision 34)
+++ /tags/stacks/webmap-0.1.0/html/js/webmap/robot.js	(revision 34)
@@ -0,0 +1,313 @@
+webmap.modules.robot = {} /// Namespace for robot modules.
+
+
+/// Create an SVG element to represent a robot from an image.
+/**
+ * \param iri         The IRI of the image.
+ * \param width       The width of the robot.
+ * \param height      The height of the robot.
+ * \param origin_left The distance from the left edge of the robot to it's origin.
+ * \param origin_top  The distance from the top edge of the robot to it's origin.
+ * 
+ * Note that the image used must have it's origin at the top left corner. This is always true for
+ * raster images, but it is your own responsibility with SVG images. SVG images should always
+ * have a viewBox attribute on the root SVG element, or scaling will not work properly.
+ * The viewBox attribute should also ensure that the origin lies at the top left corner of the
+ * SVG image.
+ */
+webmap.createImageModel = function(iri, width, height, origin_left, origin_top) {
+	if (origin_left == undefined) origin_left = width  * 0.5;
+	if (origin_top  == undefined) origin_top  = height * 0.5;
+	
+	/// The image element.
+	var image = document.createElementNS(webmap.svgns, "image");
+	image.setAttributeNS(webmap.xlinkns, "href", iri);
+	image.setAttribute("x",      0);
+	image.setAttribute("y",      0);
+	image.setAttribute("width",  width);
+	image.setAttribute("height", height);
+	image.style.overflow = "visible";
+	
+	var transform  = webmap.svg.createSVGMatrix()
+	// Translate the image so that the origin is at the specifified location.
+	.translate(-origin_left, -origin_top)
+	// Flip the Y axis of the image, since we're placing it in a Cartesian coordinate grid.
+	.translate(width * 0.5, height * 0.5)
+	.scaleNonUniform(1, -1)
+	.translate(-width * 0.5, -height * 0.5);
+	
+	image.transform.baseVal.initialize(image.transform.baseVal.createSVGTransformFromMatrix(transform));
+	return image;
+}
+
+
+
+/// Create a new robot.
+/**
+ * \param name        The name of the robot.
+ * \param model       The SVG element to render.
+ */
+webmap.Robot = function(name, model) {
+	
+	// Call parent constructor.
+	webmap.Extendable.call(this, webmap.modules.robot);
+	
+	/// Locally unique ID.
+	this.id = webmap.generateId();
+	
+	/// The name of the robot.
+	this.name = name;
+	
+	/// The map the robot is currently associated with.
+	this.map = null;
+	
+	/// True if the robot is selected.
+	this.selected = false;
+	
+	/// Position in 3D of the robot.
+	this.position = {x: 0, y: 0, z: 0};
+	
+	/// Orientation of the robot as quaternion.
+	this.orientation = {x: 0, y: 0, z: 0, w: 0};
+	
+	/// The orientation flattened to the rotation of the robot around the Z axis.
+	this.angle = 0;
+	
+	/// The odometry topic.
+	this.odometry_topic = null;
+	
+	/// The twist topic.
+	this.twist_topic = null
+	
+	/// The move topic.
+	this.move_topic = null
+	
+	/// The name of the base link.
+	this.base_link = "base_link";
+	
+	/// Indicates if the cached odometry information is valid.
+	/**
+	 * The cached odometry information is valid when the first odometry
+	 * message has arrived.
+	 */
+	this.valid = false;
+	
+	/// SVG model content representing the robot.
+	this.svg = document.createElementNS(webmap.svgns, "g");
+	
+	// Add the model to the SVG rendering.
+	this.svg.appendChild(model.cloneNode(true));
+	
+	// Add a reference back to the robot from the SVG element.
+	this.svg.robot = this;
+	
+	// Add event listeners.
+	this.mouseClickListener = this.mouseClick.bind(this);
+	this.svg.addEventListener("click", this.mouseClickListener, false);
+}
+
+webmap.extend(webmap.Extendable, webmap.Robot);
+
+/// Clean up the robot.
+/**
+ * All subscriptions and advertisements are removed, and the robot is removed from it's map.
+ */
+webmap.Robot.prototype.destroy = function() {
+	webmap.Robot.prototype.unsubscribeOdometryTopic();
+	webmap.Robot.prototype.unadvertiseTwistTopic();
+	webmap.Robot.prototype.unadvertiseMoveTopic();
+	if (this.map) this.map.removeRobot(this);
+}
+
+
+/// Set the odometry topic used by the robot.
+/**
+ * \param connection  The ROS connection for this topic.
+ * \param topic       The name of the topic.
+ * \param throttle    (Optional) The minimum time in milliseconds between receiving two updates. Defaults to 100.
+ * 
+ * The topic has to be a Pose, PoseStamped, PoseWithCovarianceStamped
+ * or Pose2D message from the geometry_msgs package.
+ */
+webmap.Robot.prototype.setOdometryTopic = function(connection, topic, throttle) {
+	if (throttle === undefined || throttle === null) throttle = 100;
+	this.unsubscribeOdometryTopic();
+	connection.subscribe(this.handleOdometry.bind(this), topic, null, throttle, null, null, null, this.id);
+	this.odometry_topic = {connection: connection, name: topic};
+}
+
+/// Unsubscribe the robot from the odometry topic.
+/**
+ * This method doesn nothing if the robot wasn't subscribed to an odometry topic.
+ */
+webmap.Robot.prototype.unsubscribeOdometryTopic = function() {
+	if (this.odometry_topic) {
+		this.odometry_topic.connection.unsubscribe(this.odometry_topic.name, this.id);
+		this.odometry_topic = null;
+	}
+}
+
+/// Set the twist topic used to control the robot.
+/**
+ * \param connection  The ROS connection for this topic.
+ * \param topic       The name of the topic.
+ */
+webmap.Robot.prototype.setTwistTopic = function(connection, topic) {
+	connection.advertise(topic, "geometry_msgs/Twist", this.id);
+	this.twist_topic = {connection: connection, name: topic};
+}
+
+/// Unadvertise the twist topic
+/**
+ * This method does nothing if the robot hasn't advertised a twist topic yet.
+ */
+webmap.Robot.prototype.unadvertiseTwistTopic = function() {
+	if (this.twist_topic) {
+		this.twist_topic.connection.unadvertise(this.twist_topic.name, this.id);
+		this.twist_topic = null;
+	}
+}
+
+/// Set the move topic used to control the robot.
+/**
+ * The move topic is used to send 2D position goals for the robot.
+ * 
+ * \param connection  The ROS connection for this robot.
+ * \param topic       The name of the move topic.
+ */
+webmap.Robot.prototype.setMoveTopic = function(connection, topic) {
+	connection.advertise(topic, "move_base_simple/goal", this.id);
+	this.move_topic = {connection: connection, name: topic};
+}
+
+/// Unadvertise the move topic
+/**
+ * If there was no move topic yet, nothing happens.
+ */
+webmap.Robot.prototype.unadvertiseMoveTopic = function() {
+	if (this.move_topic) {
+		this.move_topic.connection.unadvertise(this.move_topic.name, this.id);
+		this.move_topic = null;
+	}
+}
+
+/// Send a twist message to the robot.
+/**
+ * \param x  The linear X component.
+ * \param y  The linear Y component.
+ * \param z  The linear Z component.
+ * \param rx The angular X component.
+ * \param ry The angular Y component.
+ * \param rz The angular Z component.
+ */
+webmap.Robot.prototype.twist = function(x, y, z, rx, ry, rz) {
+	if (this.twist_topic) {
+		var msg = {
+			linear:  {x:  x, y:  y, z:  z},
+			angular: {x: rx, y: ry, z: rz}
+		};
+		this.twist_topic.connection.publish(this.twist_topic.name, msg);
+		this.notifyModules("onSendTwist", msg);
+	}
+}
+
+/// Send a position goal to the robot.
+/**
+ * \param position    A 3D vector object with x, y and z attributes representing the desired position of the base link.
+ * \param orientation A 4D quaternion object with w, x, y and z attribute representing the desired orientation of the base link.
+ */
+webmap.Robot.prototype.move = function(position, orientation) {
+	if (this.move_topic) {
+		msg = {
+			frame_id: this.base_link,
+			positions: {
+				x: position.x,
+				y: position.y,
+				z: position.z
+			},
+			orientation: {
+				x: orientation.x,
+				y: orientation.y,
+				z: orientation.z,
+				w: orientation.w
+			}
+		};
+		this.move_topic.connection.publish(this.move_topic.name, msg);
+		this.notifyModules("onSendMove", msg);
+	}
+}
+
+/// Set the selected state of the robot.
+/**
+ * Adds or removes the "selected" class to/from the SVG content.
+ * \param selected True to make the robot selected, false to make the robot deselected.
+ */
+webmap.Robot.prototype.setSelected = function(selected) {
+	this.selected = selected;
+	if (selected) {
+		this.svg.classList.add("selected");
+	} else {
+		this.svg.classList.remove("selected");
+	}
+	this.notifyModules("onSelect", selected);
+}
+
+/// Process an odometry message.
+/**
+ * \param msg The odometry message.
+ */
+webmap.Robot.prototype.handleOdometry = function(msg) {
+	var position    = null;
+	var orientation = null;
+	var angle       = null;
+	
+	// It's a Pose message.
+	if (msg.position && msg.orientation) {
+		position    = msg.position;
+		orientation = msg.orientation;
+		angle       = webmap.quaternionZ(orientation);
+	// It's a PoseStamped message.
+	} else if (msg.pose && msg.pose.position && msg.pose.orientation) {
+		position    = msg.pose.position;
+		orientation = msg.pose.orientation;
+		angle       = webmap.quaternionZ(orientation);
+	// It's a PoseWithCovarianceStamped message.
+	} else if (msg.pose && msg.pose.pose && msg.pose.pose.position && msg.pose.pose.orientation) {
+		position    = msg.pose.pose.position;
+		orientation = msg.pose.pose.orientation;
+		angle       = webmap.quaternionZ(orientation);
+	// It's a Pose2D message.
+	} else if (msg.x && msg.y && msg.theta) {
+		position    = {x: msg.x, y: msg.y, z: 0};
+		orientation = null;
+		angle       = msg.theta;
+	}
+	
+	// Check if anything actually changed.
+	// If the old data was invalid, any data is a change.
+	var dirty = !this.valid;
+	dirty |= !webmap.vectorEqual(this.position, position);
+	dirty |= this.angle !== angle;
+	
+	if (dirty) {
+		this.position    = position;
+		this.orientation = orientation;
+		this.angle       = angle;
+		this.valid       = true;
+		
+		// Update the SVG rendering.
+		var transform = webmap.svg.createSVGMatrix().translate(this.position.x, this.position.y).rotate(this.angle);
+		this.svg.transform.baseVal.initialize(this.svg.transform.baseVal.createSVGTransformFromMatrix(transform));
+		this.svg.style.display = this.valid ? "block" : "none";
+		
+		this.notifyModules("onOdometryUpdate");
+	}
+}
+
+/// Handle mouse click events.
+webmap.Robot.prototype.mouseClick = function(event) {
+	event = event || window.event;
+	event.stopPropagation();
+	event.preventDefault();
+	if (this.map) this.map.setSelected(this);
+}
Index: /tags/stacks/webmap-0.1.0/html/webmap.css
===================================================================
--- /tags/stacks/webmap-0.1.0/html/webmap.css	(revision 34)
+++ /tags/stacks/webmap-0.1.0/html/webmap.css	(revision 34)
@@ -0,0 +1,81 @@
+body {
+	background: white;
+	color: black;
+	font-family: sans;
+	font-size: normal;
+}
+
+h1 {
+	background: rgb(0, 166, 214);
+	color: white;
+	padding: 0.5ex;
+	font-size: 1.8em;
+}
+
+
+#webmap_container {
+	display: inline-block;
+	border: 1px dashed black;
+}
+
+
+.log {
+	white-space: pre-wrap;
+	border: 1px solid rgb(0, 166, 214);
+	padding: 0.5ex;
+	max-height: 5em;
+	overflow-y: auto;
+}
+
+svg.webmap {
+	image-rendering:optimizeSpeed;             /* Legal fallback                 */
+	image-rendering:-moz-crisp-edges;          /* Firefox                        */
+	image-rendering:-o-crisp-edges;            /* Opera                          */
+	image-rendering:-webkit-optimize-contrast; /* Chrome (and eventually Safari) */
+	image-rendering:optimize-contrast;         /* CSS3 Proposed                  */
+	-ms-interpolation-mode:nearest-neighbor;   /* IE8+                           */
+}
+
+.stage {
+	fill: #f00;
+	stroke: #000;
+	stroke-width: 0.01;
+}
+
+.stage.selected {
+	fill: #f66;
+	stroke: #fcc;
+}
+
+.stage_0 {
+	fill: #0a0;
+	stroke: #000;
+}
+
+.stage_1 {
+	fill: #f00;
+	stroke: #000;
+}
+
+.stage_0.selected {
+	fill: #6a6;
+	stroke: #cfc;
+}
+
+.stage_1.selected {
+	fill: #f66;
+	stroke: #fcc;
+}
+
+.laserscan > .dot {
+	fill: #f00;
+	stroke: none;
+}
+
+.stage_0 > .laserscan > .dot {
+	fill: #0a0;
+}
+
+.stage_1 > .laserscan > .dot {
+	fill: #f00;
+}
Index: /tags/stacks/webmap-0.1.0/launch/webmap.launch
===================================================================
--- /tags/stacks/webmap-0.1.0/launch/webmap.launch	(revision 34)
+++ /tags/stacks/webmap-0.1.0/launch/webmap.launch	(revision 34)
@@ -0,0 +1,5 @@
+<launch>
+	<arg name="world" default="$(find webmap)/share/test.world" />
+	<node pkg="rosbridge_server" type="rosbridge.py" name="rosbridge" output="screen"/>
+	<node pkg="stage" type="stageros" name="stageros" args="$(arg world)" output="screen"/>
+</launch>
Index: /tags/stacks/webmap-0.1.0/manifest.xml
===================================================================
--- /tags/stacks/webmap-0.1.0/manifest.xml	(revision 34)
+++ /tags/stacks/webmap-0.1.0/manifest.xml	(revision 34)
@@ -0,0 +1,10 @@
+<package>
+  <description brief="webmap">
+	WebMap is a 2D map capable of visualizing multiple robots and their sensor readings.
+  </description>
+  <author>Maarten de Vries</author>
+  <license>GPLv3</license>
+  <review status="unreviewed" notes=""/>
+  <url>http://ros.org/wiki/webmap</url>
+  <depend package="rosbridge_server"/>
+</package>
Index: /tags/stacks/webmap-0.1.0/share/test.world
===================================================================
--- /tags/stacks/webmap-0.1.0/share/test.world	(revision 34)
+++ /tags/stacks/webmap-0.1.0/share/test.world	(revision 34)
@@ -0,0 +1,72 @@
+define block model
+(
+  size [0.5 0.5 0.5]
+  gui_nose 0
+)
+
+define topurg ranger
+(
+	sensor( 			
+    range [ 0.0  30.0 ]
+    fov 270.25
+   samples 1081
+  )
+
+  # generic model properties
+  color "black"
+  size [ 0.05 0.05 0.1 ]
+)
+
+define erratic position
+(
+  size [0.35 0.35 0.25]
+  origin [-0.05 0 0 0]
+  gui_nose 1
+  drive "diff"
+  localization_origin [ 0 0 0 0 ]
+  topurg(pose [ 0.050 0.000 0 0.000 ])
+)
+
+define floorplan model
+(
+  # sombre, sensible, artistic
+  color "gray30"
+
+  # most maps will need a bounding box
+  boundary 1
+
+  gui_nose 0
+  gui_grid 0
+
+  gui_outline 0
+  gripper_return 0
+  fiducial_return 0
+  laser_return 1
+)
+
+# set the resolution of the underlying raytrace model in meters
+resolution 0.02
+
+interval_sim 100  # simulation timestep in milliseconds
+
+
+window
+( 
+  size [ 745.000 448.000 ] 
+
+  rotate [ 0.000 0 ]
+  scale 28.806 
+)
+
+# load an environment bitmap
+floorplan
+( 
+  name "willow"
+  bitmap "background.pgm"
+  size [54.0 58.7 0.5]
+  pose [ 0 0 0 90.000 ]
+)
+
+# throw in a robot
+erratic( pose [ 0 0 0 0.000 ] name "era" color "blue")
+erratic( pose [ 10 10 0 0.000 ] name "two" color "red")
Index: /tags/stacks/webmap-0.1.0/stack.xml
===================================================================
--- /tags/stacks/webmap-0.1.0/stack.xml	(revision 34)
+++ /tags/stacks/webmap-0.1.0/stack.xml	(revision 34)
@@ -0,0 +1,10 @@
+<stack>
+  <description brief="webmap">
+	WebMap is a 2D map capable of visualizing multiple robots and their sensor readings.
+  </description>
+  <author>Maarten de Vries</author>
+  <license>GPLv3</license>
+  <review status="unreviewed" notes=""/>
+  <url>http://ros.org/wiki/webmap</url>
+  <depend stack="rosbridge_suite"/>
+</stack>
