function StoresMap(	messageBundle,	
					mapCanvas,
					storesListCanvas,
					getStoresUrl,
					setFavouriteStoreUrl,
					iconDefinition,
					storeVisibilityFunction,
					favouriteStoreNumber,
					requestedStoreNumber,
					requestedZipcode,
					requestedPlace) { 

	return constructor();
	
	function constructor() {

		this.dayNames = ["maandag", "dinsdag", "woensdag", "donderdag", "vrijdag", "zaterdag", "zondag"];
		this.ONE_KILOMETER = 0.008993;					// one kilometer in latitude or longitude units
		this.ZIPCODE_ZOOMLEVEL = 16;
		this.MAX_AUTO_ZOOMLEVEL = 17;					// max zoomlevel for automatically zoomed maps based on search results
		// TODO: these bounds are rather large, see map.js in ahws which has tighter bounds. This will cause more risk of
		// a foreign location to be found when the user enters a city name that does not exist 
		this.boundsHolland = new GLatLngBounds(new GLatLng(49.724479188712984, 0.791015625), new GLatLng(54.08517342088679, 10.107421875));
		this.maxStoresToList = 10;
		
		this.messageBundle = messageBundle;
		this.mapCanvas = mapCanvas;						// The DOM object containing the Google GMap2 object
		this.storesGoogleMap = undefined;				// The Google GMap2 object
		this.storesListCanvas = storesListCanvas;		// The DOM object that will show the list of stores in
														// the current viewport
		this.storeInfoCanvas = createHiddenCanvas(mapCanvas.parentNode, "storeInfoCanvas", 105);	// The DOM object that shows the store info and opening hours
		this.storeInfoFader = new Fader(this.storeInfoCanvas, 1.0, 20, 0.3);
		this.storeInfoCanvas.onmouseover = this.storeInfoFader.restore;
		//this.storeInfoCanvas.onmouseout = this.storeInfoFader.start;
		this.messageCanvas = createHiddenCanvas(mapCanvas.parentNode, "messageCanvas", 200);		// The area that shows status messages etc.
		this.getStoresUrl = getStoresUrl;					// The URL to the controller that can deliver stores info
		this.setFavouriteStoreUrl = setFavouriteStoreUrl;	// The URL to the controller that can deliver stores info
		this.storesCollection = {};							// The collection of all stores, 'indexed' by store number
		this.requestedStoreNumber = requestedStoreNumber;	// the store that was requested through the URL
		this.favouriteStoreNumber = favouriteStoreNumber;	// the customer's favourite store
		this.iconCollection = createIconCollection(iconDefinition);
		this.storeVisibilityFunction = storeVisibilityFunction;
		this.geocoder = new GClientGeocoder();
		this.geocoder.setBaseCountryCode("nl");
		this.lastEnteredAddress = undefined;
		this.explicitSearch = false;
		
		this.searchByZipcode = searchByZipcode;						// make searchByZipcode publicly available
		this.searchByCity = searchByCity;							// make searchByCity publicly available
		this.searchByCriteria = searchByCriteria;					// make searchByCriteria publicly available;
		
		this.zipCodeMarker = null;									// marker for marking the location of the zip code that the user entered

		if (GBrowserIsCompatible()) {
			storesGoogleMap = new GMap2(mapCanvas);
			this.storesGoogleMap = storesGoogleMap;
			this.geocoder.setViewport(boundsHolland);
			setMapArea(boundsHolland);
			storesGoogleMap.addControl(new GLargeMapControl());
			storesGoogleMap.addControl(new GMapTypeControl());
			storesGoogleMap.enableScrollWheelZoom();
			storesGoogleMap.enableDoubleClickZoom();
			GEvent.addListener(storesGoogleMap, "movestart", storeInfoFader.hide);
			GEvent.addListener(storesGoogleMap, "moveend", computeMarkersVisibility);
			loadAllStores(getStoresUrl);
		}
		this.debugConsole = document.getElementById("debugConsole");
		return this;
	}
	
	/**
	 * Returns the message in the injected messageBundle for the given key, or
	 * '?{key}' if no message is found for that key.
	 * @param {Object} key	the key to return a localized message for
	 */
	function msg(key) {
		var message = this.messageBundle[key]; 
		return message ? message : '?' + key;
	}
	
	function createIconCollection(iconDefinition) {
		var iconCollection = new Array();
		for (format in iconDefinition.icons) {
			var normalIconUrl = iconDefinition.imageDirectory + "/" + iconDefinition.icons[format].normal;
			var shadowIconUrl = iconDefinition.imageDirectory + "/" + iconDefinition.icons[format].shadow;
			var icon = new GIcon(G_DEFAULT_ICON, normalIconUrl);
			icon.printImage = normalIconUrl;
			icon.shadow = shadowIconUrl;
			icon.printShadow = shadowIconUrl;
			icon.iconSize = new GSize(iconDefinition.normalSize.width, iconDefinition.normalSize.height);
			icon.shadowSize = new GSize(iconDefinition.shadowSize.width, iconDefinition.shadowSize.height);  
			icon.iconAnchor = new GPoint(iconDefinition.anchor.x, iconDefinition.anchor.y);
			icon.infoWindowAnchor = new GPoint(iconDefinition.anchor.x, iconDefinition.anchor.y);
			iconCollection[format] = icon;
		}
		return iconCollection;
	}
		

	function loadAllStores(URL) {
		showBusy(msg("maps.busymessage.loading"));
		GDownloadUrl(URL, function(data, responseCode) {
			var stores = eval(data);
			var discarded = 0;
			for (var index=0; index < stores.length; index++) {
				var store = stores[index];
				if (store.lat == 0 && store.lng == 0) {
					discarded++;
				} else {
					storesCollection[store.no] = getStoreWithMarker(store);
				}
			}
			hideBusy();
			if (requestedZipcode != '') {
				searchByZipcode(requestedZipcode, 5);
			} else if (requestedPlace != '') {
				searchByCity(requestedPlace);
			} else if (requestedStoreNumber > 0) {
				highlightStore(storesCollection[requestedStoreNumber]);
			} else if (favouriteStoreNumber > 0) {
				highlightStore(storesCollection[favouriteStoreNumber]);
			} else {
				showAreaWithAllStores();
			}
		});
	}

	/**
	 * Sets up the map to display the smallest area that contains ALL stores in the stores collection.
	 */
	function showAreaWithAllStores() {
		var bounds = new GLatLngBounds();
		for (var index in storesCollection) {
			bounds.extend(storesCollection[index].marker.getLatLng());
		}
		setMapArea(bounds);
		storesGoogleMap.zoomIn();
	}


	function getStoreWithMarker(store) {
		store.icon = this.iconCollection[store.format];
		store.zoomlevel = getMinimumZoomLevel(store);
		store.name = sprintf("%s %s, %s", msg("maps.companyname"), store.street, store.city);
		var popup =  sprintf("%s %s %s, %s", msg("maps.companyname"), store.street, store.housenr, store.city).replace(/&amp;/g, '&');
		var gMarker = new GMarker(new GLatLng(store.lat, store.lng), {
			icon: store.icon,
			title: popup
		});
		store.marker = gMarker;
		store.latlng = gMarker.getLatLng();		// although the store already has a lat and lng member,
												// store the marker's GLatLng also, for quick access while
												// determining a store's visibility  
		gMarker.store = store;
		// Disabled - too flashy
		//GEvent.addListener(gMarker, "mouseover", markerFocusHandler);
		//GEvent.addListener(gMarker, "mouseout", markerBlurHandler);
		GEvent.addListener(gMarker, "click", markerClickHandler);
		GEvent.addListener(gMarker, "dblclick", markerDoubleClickHandler);
		return store;
	}
	
	// In these four event handlers below, 'this' refers to the GMarker causing the event
	function markerFocusHandler(event) {
		showStoreInfo(this.store);
	}
	
	function markerBlurHandler(event) {
		storeInfoFader.start();
	}
	
	function markerClickHandler(event) {
		showStoreInfo(this.store);	
	}
	
	function markerDoubleClickHandler(event) {
		highlightStore(this.store);
	}
	
	
	/** 
	 * Creates and shows a DHTML popup with the store's address and opening hours. 
	 * The popup is shown to the right of the store's icon, with the top of the popup at the
	 * same vertical position as the top of the map.
	 * @param {Object} store	the store to show a popup for
	 */
	function showStoreInfo(store) {
		var storePixelPosition = storesGoogleMap.fromLatLngToContainerPixel(store.marker.getLatLng());
		storeInfoCanvas.innerHTML = createStoreInfoHtml(store);
		storeInfoCanvas.appendChild(createSetFavouriteCheckboxDiv(store));
		storeInfoCanvas.appendChild(createElement("div", {}, createDaysInfoHtml(store)));
		storeInfoCanvas.appendChild(createEnterAddressDiv(store));
		storeInfoCanvas.appendChild(createCloseBox());
		var xPosition = computeHorizontalPosition(storePixelPosition.x, mapCanvas.offsetWidth, storeInfoCanvas.offsetWidth);
		storeInfoCanvas.style.left = mapCanvas.offsetLeft + xPosition + 'px';
		storeInfoCanvas.style.top = (mapCanvas.offsetTop) + 'px';
		storeInfoFader.restore();
	}
	
	function createCloseBox() {
		var closeBox = createElement("div", {id: "storeInfoCloseButton", onclick: storeInfoFader.hide}, "x")
		return closeBox;
	}
	
	/**
	 * Creates and returns the HTML that displays the store's address and opening hours.
	 * @param {Object} store	the store to create HTML data for 
	 */
	function createStoreInfoHtml(store) {
		var info = '';
		info += sprintf('<div id="popup-header">%s</div>', store.name);
		info += sprintf('<div id="popup-address">%s %s, %s, %s<br/>Telefoon: %s</div>',
						store.street, store.housenr, store.zip, store.city, 
						(store.phone ? store.phone.replace('-', ' - ') : '-'));
		return info;
	}
	
	/**
	 * Creates and returns a DOM checkbox element that when clicked, will call the toggleFavouriteStore() method.
	 * @param {Object} store	the store to create the checkbox for
	 */
	function createSetFavouriteCheckboxDiv(store) {
		var checked = (store.no == favouriteStoreNumber);
		var checkboxDiv = createElement("div", {id: "popup-setfavourite"});
		var checkbox = createElement("input", {type: "checkbox", defaultChecked: checked});
		checkbox.onclick = createFunctionClosure(
			function(storeNumber, checkboxElement) {
				toggleFavouriteStore(storeNumber, checkboxElement.checked);
			}, store.no, checkbox);
		checkboxDiv.appendChild(checkbox);
		checkboxDiv.appendChild(createElement("span", {}, msg("maps.option.savefavourite")));
		checkboxDiv.appendChild(createElement("a", {href: msg("maps.link.favouritehelp")}, msg("maps.hint.savefavourite")));
		return checkboxDiv;
	}
	
	function toggleFavouriteStore(storeNumber, isFavourite) {
		var currentStore = storesCollection[storeNumber];
		var newFavouriteStoreNumber = isFavourite ? storeNumber : 0;
		showBusy(msg("maps.busymessage.saving"));
		GDownloadUrl(setFavouriteStoreUrl + "/" + newFavouriteStoreNumber,
			function(data, responseCode) {
				favouriteStoreNumber = newFavouriteStoreNumber;
				hideBusy();
				showStoreInfo(currentStore);
		});
	}
	
	function createEnterAddressDiv(store) {
		var enterAddressDiv = createElement("form", {id: "popup-enteraddress"});
		enterAddressDiv.appendChild(createElement("span", {}, msg("maps.label.routefrom")));
		var textbox = createElement("input", {type: "text", className: "textbox"});
		if (lastEnteredAddress != undefined) {
			textbox.value = lastEnteredAddress;
		} else {
			textbox.value = msg("maps.hint.routefrom");
			textbox.className = "textbox input-hint";
			textbox.onfocus = function() { 
				this.value = '';
				this.className = "textbox";
				this.onfocus = function() {};
			};
		}
		var button = createElement("input", {id: "showbutton", type: "button", className: "_button", value: msg("maps.button.route")});
		enterAddressDiv.action = "javascript: dummy();";
		enterAddressDiv.onsubmit = createFunctionClosure(function(store, textbox) {
				if (textbox.value == '' || textbox.className == 'textbox input-hint') {
					alert(msg("maps.alert.enteraddress"));
				} else {
					lastEnteredAddress = textbox.value;
					openRouteDescription(textbox.value, store.street + ' ' + store.housenr + ', ' + store.city);
				}
			}, store, textbox
		);
		button.onclick = enterAddressDiv.onsubmit; 
		enterAddressDiv.appendChild(textbox);
		enterAddressDiv.appendChild(button);
		return enterAddressDiv;
	}
	
	function openRouteDescription(source, dest) {
		var URL = sprintf(msg("maps.link.showroute"), encodeURI(source), encodeURI(dest));  
		window.open(URL);
	}
	
	/**
	 * Computes the horizontal position for the store info window, given the x-position of the
	 * store marker, the width of the map canvas, and the width of the store info window.
	 * The current logic is to return a position that is to the right of the store marker. 
	 * @param {Object} storeXposition	the x-position of the store marker (relative to the map's left edge)
	 * @param {Object} mapWidth			the width of the map canvas
	 * @param {Object} infoWidth		the width of the store info window
	 */
	function computeHorizontalPosition(storeXposition, mapWidth, infoWidth) {
		return storeXposition + 20;
		/*
		var offset =  20;	// offset between the store marker, and the edge of the info window
		if (storeXposition + offset + infoWidth - mapWidth <= (infoWidth / 2)) {
			return storeXposition + offset;
		} else {
			return storeXposition - offset - infoWidth;
		}
		*/
	}
	
	/**
	 * Creates and returns an HTML table that shows the store's opening hours.
	 * @param {Object} store	the store to create HTML with opening hours for
	 */
	function createDaysInfoHtml(store) {
		var info = sprintf('<div id="popup-hoursheader">%s</div>', msg("maps.label.openinghours"));
		info += '<div id="popup-hoursblock"><table><tr><th></th>\n';
		info += sprintf('<th colspan="2" class="popup-weekheader">%s</th>', msg("maps.label.thisweek"));
		info += sprintf('<th colspan="2" class="popup-weekheader nextweek">%s</th>', msg("maps.label.nextweek"));
		info += '</tr>';
		for (dayNr = 0; dayNr <= 6; dayNr++) {
			info += sprintf('<tr><td class="popup-dayname">%s</td>%s%s</tr>\n', 
					dayNames[dayNr],
					createDayInfoHtml(store.hours[dayNr], ''), 
					createDayInfoHtml(store.hours[dayNr + 7], 'nextweek'));
		}
		info += '</table><p id="disclaimer"><b>Let op:</b> Dit zijn standaard openingstijden van deze winkel.<br>Neem voor actuele informatie telefonisch contact op met de winkel.</p></div>';
		return info;
	}
	
	/**
	 * Creates and returns an HTML cell with the opening hours for a single day.
	 * @param {Object} day	a day object with members D (date), F (from), and U (until);
	 *						if F and U are undefined but C is true, then the store is closed on that day,
	 *						if F and U and C are undefined then the state is not known for that day. 
	 */
	function createDayInfoHtml(day, week) {
		today = new Date()
		month = day.D.substring(3,5) - 1;
		measureDate = new Date(today.getFullYear(), today.getMonth(), today.getDate())
		storeDate = new Date(day.D.substring(6,10), month, day.D.substring(0,2)) 

		var info =''
		if (storeDate < measureDate) {		 
			info += sprintf('<td class="popup-date %s">%s</td>', week, "");
			info += sprintf('<td class="popup-hours %s">%s </td>', week, "" );
		} else {
			info += sprintf('<td class="popup-date %s">%s</td>', week, day.D);
		
			if (day.F == undefined) {
				info += sprintf('<td class="popup-nohours %s">%s</td>', week, day.C ? msg("maps.label.closed") :
					msg("maps.label.unknown"));
			} else {
				info += sprintf('<td class="popup-hours %s">%s - %s</td>', week, insertColon(day.F), insertColon(day.U));
			}
		}
		return info;
	}
	
	/**
	 * Converts a "hhmm" time string to "hh:mm".
	 * @param {Object} time		a time string in the format "hhmm"
	 */
	function insertColon(time){
		return time.slice(0, 2) + ":" + time.slice(2);
	}
	
	function getMinimumZoomLevel(store){
		if (store.format == "XL") {
			return 0; // visible on all levels
		}
		return 9; // default - visible only on zoom level >= 9
	}
	
	function highlightStore(store){
		setStoreVisibility(store, true);
		storesGoogleMap.setCenter(store.marker.getLatLng(), ZIPCODE_ZOOMLEVEL);
		showStoreInfo(store);
	}
	
	/**
	 * Called by map.js, in response to the user entering a zipcode and a radius.
	 * @param {Object} zipcode	the zipcode - e.g. 1234 or 1234AB (all other formats are invalid)
	 * @param {Object} radius	the radius around the zipcode - some number representing kilometres
	 */
	/* public */
	function searchByZipcode(zipcode, radius) {
		// Only accept zipcodes of the form 1234 or 1234AB (with maybe whitespace inbetween digits and letters)
		if (/^\s?\d{4}\s?([a-zA-Z]{2})?\s?$/.test(zipcode)) {
			zipcode = zipcode.replace(/ /g, '');	// remove all whitespace
			lastEnteredAddress = zipcode;
			explicitSearch = true;
			searchByAddress(zipcode, radius, msg("maps.locality.zipcode"));
		} else {
			lastEnteredAddress = '';
			alert(sprintf("'%s' %s", zipcode, msg("maps.alert.isinvalidzipcode")));
		}
	}
	
	/**
	 * Called by map.jsp, in response to the user entering a city name.
	 * @param {Object} city		the city name
	 */
	/* public */ 
	function searchByCity(city) {
		explicitSearch = true;
		removeZipCodeMarker();
		var matchingStores = new Array();
		var cityLowerCase = city.toLowerCase();
		for (var index in storesCollection) {
			var store = storesCollection[index];
			if (store.city.toLowerCase() == cityLowerCase) {
				matchingStores.push(store);
			}
		}
		var bounds = getBoundsFromStoreList(matchingStores);
		
		if (matchingStores.length == 0) {
			setMapAreaToClosestByAddress(city, msg("maps.locality.place"));
		} else {
			setMapArea(stretchBounds(bounds));
		}
	}
	
	function getBoundsFromStoreList(matchingStores) {
		var bounds = new GLatLngBounds();
		for (var index in matchingStores) {
			var store = matchingStores[index];
			bounds.extend(store.marker.getLatLng());
		}
		return bounds;
	}

	/**
	 * Sets the map area to the smallest bounds in which at least one store is located.
	 */
	function setMapAreaToClosestByAddress(address, locationName) {

		// find the coordinates of this city
		geocoder.getLatLng(address + ', Nederland', function(point) {
      		if ((! point) || (! boundsHolland.containsLatLng(point))) {
        		alert(sprintf("%s '%s' %s", locationName, address, msg("maps.alert.isunknownaddress")));
      		} else {
				// calculate the smallest distance to a store
				var minDist = 100;
				for (var index in storesCollection) {
					var store = storesCollection[index];
					var dist = point.distanceFrom(storesCollection[index].latlng) / 1000;
					if (dist < minDist) {
						minDist = dist;
					}
				}

				// set the map area so that the closest store is visible
				var latLngRadius = (minDist + 0.5) * ONE_KILOMETER;
				var sw = new GLatLng(point.lat() - latLngRadius, point.lng() - latLngRadius);
				var ne = new GLatLng(point.lat() + latLngRadius, point.lng() + latLngRadius);
				var bounds = new GLatLngBounds(sw, ne);

				setMapArea(bounds, point);
			}
		});
	}
	
	function searchByAddress(address, radius, locationName) {
		removeZipCodeMarker();
		geocoder.getLatLng(address + ', Nederland', function(point) {
      		if ((! point) || (! boundsHolland.containsLatLng(point))) {
        		alert(sprintf("%s '%s' %s", locationName, address, msg("maps.alert.isunknownaddress")));
      		} else {
      			var matchingStores = new Array();
				for (var index in storesCollection) {
					var store = storesCollection[index];
					var dist = point.distanceFrom(storesCollection[index].latlng) / 1000;
					if (dist < radius) {
						matchingStores.push(store);
					}
				}
				addZipCodeMarker(point, address);
				
				var bounds = getBoundsFromStoreList(matchingStores);
				bounds.extend(point);
				setMapArea(stretchBounds(bounds));
			}
		});
	}
	
	/**
	 * Called by map.jsp, in reponse to the user making changes to the stores openinghours filters.
	 */
	/* public */ 
	function searchByCriteria() {
		explicitSearch = true;
		computeMarkersVisibility();
	}
	
	// debug code
	function showLocations(address) {
		geocoder.getLocations(address + ', Nederland', function(response) {
			var text = sprintf('Status = %s, # of addresses is %s\n', response.Status.code,
				(response.Placemark ? response.Placemark.length : 0));
			if (response.Placemark) {
				for (var i=0; i < response.Placemark.length; i++) {
					var place = response.Placemark[i];
					text += sprintf("Place %s : %s (accuracy %s)\n", i, place.address, place.AddressDetails.Accuracy);
				}
			}
			alert(text);
		});
	}
	

	/**
	 * Change the Google Maps area of interest to the given bounds and (optional) center.
	 * If the center is not given, it is deduced from the bounds.
	 */
	function setMapArea(bounds, center) {
		zoomLevel = storesGoogleMap.getBoundsZoomLevel(bounds); 
		// When this function is called with the bounds of just 1 store,
		// it will have the maximum zoom level (21), but that is not 
		// useful, so never zoom in to maximum level
		if (zoomLevel > this.MAX_AUTO_ZOOMLEVEL) {
			zoomLevel = this.MAX_AUTO_ZOOMLEVEL;
		}
		storesGoogleMap.setCenter((center) ? center : (bounds.getCenter()), zoomLevel);
		storeInfoFader.hide();
	}
	
	/**
	 * Stretch the bounds by a 10% margin, so when the markers are right at the edge, they do not
	 * fall off the map.
	 */
	function stretchBounds(bounds) {
		var pointNorthEast = bounds.getNorthEast();
		var pointSouthWest = bounds.getSouthWest();

		var latAdjustment = (pointNorthEast.lat() - pointSouthWest.lat()) * .05;
		var lngAdjustment = (pointNorthEast.lng() - pointSouthWest.lng()) * .05;

		var newPointNorthEast = new GLatLng(pointNorthEast.lat() + latAdjustment, pointNorthEast.lng() + lngAdjustment);
		var newPointSouthWest = new GLatLng(pointSouthWest.lat() - latAdjustment, pointSouthWest.lng() - lngAdjustment);


		bounds = new GLatLngBounds();

		bounds.extend(newPointNorthEast);
		bounds.extend(newPointSouthWest);
		
		return bounds;
	}
	
	/**
	 * Called implicitly by the "moveend" listener on the GMap2 object, and explicitly by searchByCriteria(),
	 * this method determines (with the help of another function) which stores should currently be
	 * visible.  
	 */
	function computeMarkersVisibility() {
		var visibleStores = new Array();
		var currentBounds = storesGoogleMap.getBounds();
		var filteredOut = 0;
		for (var index in storesCollection) {
			var store = storesCollection[index];
			var newVisibility = shouldStoreBeVisible(store, currentBounds);
			if (newVisibility.visible) {
				visibleStores.push(store);
			} else if (newVisibility.filtered) {
				filteredOut++;
			}
			setStoreVisibility(store, newVisibility.visible);
		}
		listVisibleStores(visibleStores);
		if (explicitSearch && visibleStores.length == 0) {
			alert(msg((filteredOut > 0) ? "maps.label.foundnostores.withfilters" : "maps.label.foundnostores"));
		}
		explicitSearch = false;	// reset this flag which might have been set earlier
	}
	
	/**
	 * Creates a new closure for calling the specified function with any arguments that may be passed also
	 * @param {Object} _function	the function to be called by the new closure
	 * @param 	optional other parameters: will all be passed in to _function() by the closure
	 */
	function createFunctionClosure(_function) {
		var args = Array.prototype.slice.call(arguments);
		args.shift();	// shift out the function reference (0th argument)
		return function() {
			_function.apply(undefined, args); 
		};
	}

	/**
	 * A comparator function for store objects, that sorts stores first by zipcode, then street,
	 * then (numerical) housenumber.
	 * @param {Object} a	store A
	 * @param {Object} b	store B
	 */
	function storeComparator(a, b) {
		if (a.zip < b.zip) {
			return -1;
		} else if (a.zip > b.zip) {
			return 1;
		} else {
			if (a.street < b.street) {
				return -1;
			} else if (a.street > b.street) {
				return 1;
			} else {
				return a.housenr - b.housenr;	// '-' operator forces numerical comparison! 
			}
		}
	}
	
	function listVisibleStores(stores) {
		storesListCanvas.innerHTML = '';
		if (stores.length == 0 || stores.length > maxStoresToList) {
			return;
		}
		stores.sort(storeComparator);
		storesListCanvas.appendChild(createElement('h2', null, msg("maps.label.foundstores")));
		Cufon.refresh('#other h2');
		var ul = storesListCanvas.appendChild(document.createElement('ul'));
		ul.className = 'more yellow stores';
		for (var index=0; index < stores.length; index++) {
			var store = stores[index], link = document.createElement("a");
			link.setAttribute('href', '#winkel')
			link.onclick = createFunctionClosure(highlightStore, store); 
			var html = sprintf('<span class="street">%s %s</span> ', store.street, store.housenr);
			html += sprintf('<span class="zip">%s</span> ', store.zip);
			html += sprintf('<span class="city">%s</span>', store.city);
			link.innerHTML = html;
			var li = document.createElement('li');
			li.appendChild(link);
			ul.appendChild(li);
		}
	}
	
	function setStoreVisibility(store, visible) {
		if (visible) {
			if (!store.visible) 
				storesGoogleMap.addOverlay(store.marker);
		}
		else {
			if (store.visible) 
				storesGoogleMap.removeOverlay(store.marker);
		}
		store.visible = visible;
		return visible;
	}

	/**
	 * Returns whether the given store should be considered visible.
	 * Both the given bounds, as well as the storeVisibilityFunction (provided by map.jsp) are
	 * used to determine the visibility.
	 * @param {Object} store	the store
	 * @param {Object} bounds	the bounds of the current map viewport
	 * @return				{visible, filtered} :	visible=true -> the store is visible;
	 * 												filtered=true -> the store is not visible
	 * 													because of the storeVisibilityFunction
	 */
	function shouldStoreBeVisible(store, bounds) {
		// To maximize performance, all other tests should be in increasing order of computational cost !
		if (storesGoogleMap.getZoom() < store.zoomlevel) {
			return {visible: false, filtered: undefined};
		}
		if (! storeVisibilityFunction(store)) {
			return {visible: false, filtered: true};
		};
		if (! bounds.containsLatLng(store.latlng)) {
			return {visible: false, filtered: false};
		}
		return {visible: true, filtered: false};
	}
	
	function createHiddenCanvas(parent, id, zIndex) {
		var canvas = document.createElement("div");
		canvas.id = id; 
		canvas.style.position = "absolute";
		canvas.style.visibility = "hidden";
		canvas.style.zIndex = zIndex + '';
		parent.appendChild(canvas);
		return canvas;
	}
	
	function showBusy(message) {
		messageCanvas.innerHTML = message;
		messageCanvas.style.visibility = "visible";
		messageCanvas.style.left = (mapCanvas.offsetLeft + mapCanvas.offsetWidth / 2) + 'px' ;
		messageCanvas.style.top = (mapCanvas.offsetTop + mapCanvas.offsetHeight / 2) + 'px';
	}
	
	function hideBusy() {
		messageCanvas.style.visibility = "hidden";
	}
	
	function removeZipCodeMarker() {
		if (this.zipCodeMarker != null) {
			storesGoogleMap.removeOverlay(this.zipCodeMarker);
			this.zipCodeMarker = null;
		}		
	}
	
	function addZipCodeMarker(point, label ) {
		this.zipCodeMarker = new GMarker(point, { title: label });
		storesGoogleMap.addOverlay(this.zipCodeMarker);
	}
	
	// --------- these functions are for debugging purposes only
	function showMembers(object){
		var s = '';
		for (var i in object) {
			s += i + ' = ' + object[i] + '\n';
		}
		return s;
	}
	
	function trace(mytext) {
		debugConsole.innerHTML += mytext + "<br>";
	}
	// ------------ end of functions for debugging purposes
	
	return this;	// keep Javascript engine happy
}

/**
 * Creates a new DOM element, with optional attributes and optional body text.
 * @param {Object} elementName	the name of the element to create
 * @param {Object} attributes	the attributes for the element (optional)
 * @param {Object} bodyText		the body text for the element (optional)
 */
function createElement(elementName, attributes, bodyText){
	var element = document.createElement(elementName);
	if (attributes) {
		for (index in attributes) {
			element[index] = attributes[index];
		}
	}
	if (bodyText) {
		element.innerHTML = bodyText;
	}
	return element;
}
	

/**
 * Models a Fader object, that can make an arbitrary DOM element appear to slowly fade away.
 * @param {Object} element				the DOM element to fade away
 * @param {Object} durationInSeconds	the duration in seconds that the fade will take
 * @param {Object} changesPerSecond		the # of times / second that the element's opacity is adjusted
 * @param {Object} opacityMinimum		when this opacity level is reached, the element will disappear completely
 */
function Fader(element, durationInSeconds, changesPerSecond, opacityMinimum) {

	return constructor();
	
	function constructor() {
		this.element = element;
		this.duration = durationInSeconds;
		this.interval = 1 / changesPerSecond;
		this.cutoff = opacityMinimum;
		this.start = start;
		this.restore = restore;
		this.hide = hide;
		this.fadeTimer = 0;
		this.opacity = 1;
		this.opacityDelta = 1 / (durationInSeconds * changesPerSecond);
		return this;
	}
	
	/* public */
	function start() {
		if (fadeTimer > 0) {
			return;
		}
		opacity = 1;
		fadeTimer = setInterval(fade, interval * 1000);
	}
	/* public */
	function restore() {
		stopTimer();
		element.style.opacity = 1;
		element.style.visibility = "visible";
	}
	/* public */
	function hide() {
		stopTimer();
		element.style.visibility = "hidden";
		element.style.opacity = 1;
	}
	/* private */
	function fade() {
		opacity -= opacityDelta;
		if (opacity > cutoff) {
			element.style.opacity = opacity;
		} else {
			hide();
		}
	}
	/* private */
	function stopTimer() {
		if (fadeTimer > 0) {
			clearTimeout(fadeTimer);
		}
		fadeTimer = 0;
	}
	/* private */
	function setOpacity(value) {
		element.style.opacity = value;
		element.style.filter = 'alpha(opacity = 50)';
	}
	return this;
}

function parseDateMMDDYYYY(string) {
	var parts = string.split("-");
	return new Date(parts[2], parts[1]-1, parts[0]);
}

function dummy() {}

/**
 * sprintf() for JavaScript v.0.4
 *
 * Copyright (c) 2007 Alexandru Marasteanu <http://alexei.417.ro/>
 * Thanks to David Baird (unit test and patch).
 *
 * This program is free software; you can redistribute it and/or modify it under
 * the terms of the GNU General Public License as published by the Free Software
 * Foundation; either version 2 of the License, or (at your option) any later
 * version.
 *
 * This program is distributed in the hope that it will be useful, but WITHOUT
 * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
 * FOR A PARTICULAR PURPOSE. See the GNU General Public License for more
 * details.
 *
 * You should have received a copy of the GNU General Public License along with
 * this program; if not, write to the Free Software Foundation, Inc., 59 Temple
 * Place, Suite 330, Boston, MA 02111-1307 USA
 */

function str_repeat(i, m) { for (var o = []; m > 0; o[--m] = i); return(o.join('')); }

function sprintf () {
  var i = 0, a, f = arguments[i++], o = [], m, p, c, x;
  while (f) {
    if (m = /^[^\x25]+/.exec(f)) o.push(m[0]);
    else if (m = /^\x25{2}/.exec(f)) o.push('%');
    else if (m = /^\x25(?:(\d+)\$)?(\+)?(0|'[^$])?(-)?(\d+)?(?:\.(\d+))?([b-fosuxX])/.exec(f)) {
      if (((a = arguments[m[1] || i++]) == null) || (a == undefined)) throw("Too few arguments.");
      if (/[^s]/.test(m[7]) && (typeof(a) != 'number'))
        throw("Expecting number but found " + typeof(a));
      switch (m[7]) {
        case 'b': a = a.toString(2); break;
        case 'c': a = String.fromCharCode(a); break;
        case 'd': a = parseInt(a); break;
        case 'e': a = m[6] ? a.toExponential(m[6]) : a.toExponential(); break;
        case 'f': a = m[6] ? parseFloat(a).toFixed(m[6]) : parseFloat(a); break;
        case 'o': a = a.toString(8); break;
        case 's': a = ((a = String(a)) && m[6] ? a.substring(0, m[6]) : a); break;
        case 'u': a = Math.abs(a); break;
        case 'x': a = a.toString(16); break;
        case 'X': a = a.toString(16).toUpperCase(); break;
      }
      a = (/[def]/.test(m[7]) && m[2] && a > 0 ? '+' + a : a);
      c = m[3] ? m[3] == '0' ? '0' : m[3].charAt(1) : ' ';
      x = m[5] - String(a).length;
      p = m[5] ? str_repeat(c, x) : '';
      o.push(m[4] ? a + p : p + a);
    }
    else throw ("Huh ?!");
    f = f.substring(m[0].length);
  }
  return o.join('');
}
