/*

	BBC World Service | Bangladesh River Journey
	A mashup of Twitter, Flickr & the BBC Diary with Google Maps
	--------------------------------------------
	
	Content and data for the Bangladesh River Journey
	is available via an API (application programming interface):
	http://bangladeshboat.welcomebackstage.com
	
	Information on microformats and other technologies in this
	application is available at:
	http://dharmafly.com/blog/bangladeshboat


	-/-/-/-/-/-/-/-/-/-/-
	

	this.code.authors = {
		front: 'Premasagar Rose',
		back: 'Annesley Newholm',
		with: ['Pete Lambert', 'Mikel Maron']
	}
	
	
*/


// Mashup object
var mashup;


// Greasemonkey API
var addFilter, applyFilters;


// Settings
var settings = {
	/* DOM values */
	dom: {
		mashup_id: 'bangladeshboat', // Id of main mashup element (all posts within this)
		map_id: 'map', // Id of the map element
		mashup_css: 'mashup.css'
	},
	
	/* Post types */
	postTypes: [
		{type:'flickr', nodeId:'photos'},
		{type:'twitter', nodeId:'tweets'},
		{type:'bbcDiary', nodeId:'entries'}
	],
	
	/* Filepaths */
	paths: {
		api_url_base: 'outputCaches/',
		gps_url: 'outputCaches/all___gps_____.json?nocache=' + new Date().getTime()
	},
		
	/* Ajax timeouts */
	ajax: {
		attempts_num: 2, // No. of times to try an Ajax request before failure
		attempts_wait: 500 // milliseconds before retry
	},
	
	/* Image caching */
	imageCaching: true,
	
	/* Map defaults */
	map: {
		custom_infowindow: true,
		
		defaults: {
			lat: 23.491759246850467,
			lng: 90.4010009765625,
			maptype: G_HYBRID_MAP,
			zoom: 7,
			maxzoom: 14,
			minzoom: 2
		},
		
		routes: {
			/* values for 'Proposed Route' polyline */
			proposed: {
				color: '#FEE5BD',
				width: 3,
				opacity: 1,
				waypoints: [
					{lat:23.228247, lng:90.646044},
					{lat:22.787667, lng:90.651444},
					{lat:22.380556, lng:90.238953},
					{lat:21.884056, lng:89.874},
					{lat:22.446889, lng:89.5875},
					{lat:22.544722, lng:89.739444},
					{lat:22.700125, lng:90.375031},
					{lat:23.769078, lng:89.790133},
					{lat:24.478278, lng:89.716056},
					{lat:25.128917, lng:89.7185},
					{lat:24.025472, lng:89.657056},
					{lat:23.824306, lng:89.539694},
					{lat:23.798833, lng:89.582389}
				]
			},
			
			/* values for 'Actual Route' polyline */
			actual: {
				color: '#FF9900',
				width: 3,
				opacity: 1				
			}
		},
		
		
		// Internal variables on GMap2.infoWindow (api v2.93)
		gmElements: {
			marker: {
				icon_imgs: 'T'
			},
			infoWindow: {
				balloon: 'ma',
				shadow: 'zc'
			}
		}
	}
};



// Initialise script
scriptInit();




//////////////////////////////////////////////////////////

/* POST CLASS - object to represent a post to Flickr, Twitter, etc. */


// Post object class
function Post(){
	// Populate with passed object
	if (arguments.length){
		extend(this, arguments[0]);
	}
	applyFilters('post', this);
}

// Check if post is valid for this mashup
Post.isValid = function(post){
	for (var i=0; i < settings.postTypes.length; i++){
		if (post.type == settings.postTypes[i].type){
			return true;
		}
	}	
	return false;
};

// Get HTML for the info window for this post
Post.prototype = {	
	getHtml: function(){
		var html = '';		
		
		switch (this.type){
			case 'flickr':
			html += '<span class="entry-content">' + this.getHtmlAnchor('<img src="' + this.src + '" alt="' + this.title + '" title="' + this.title + '" />') + '</span>';
			html += '<h4 class="entry-title">' + this.getHtmlAnchor() + '</h4>';
			html += '<p>' + this.description + '</p>';
			html += '<abbr title="' + this.date + '" class="published">' + this.getHtmlAnchor(timeSince(this.timestamp), 'See this photo on Flickr') + '</abbr>';
			break;
			
			case 'twitter':
			html += '<p class="entry-title entry-content">' + this.description + '</p>';
			html += '<abbr title="' + this.date + '" class="published">' + this.getHtmlAnchor(timeSince(this.timestamp), 'Read this post on Twitter') + '</abbr>';
			break;
			
			case 'bbcDiary':
			html += '<h4 class="entry-title">' + this.getHtmlAnchor() + '</h4>';
			html += '<p class="entry-content">' + this.description + '</p>';
			html += '<abbr title="' + this.date + '" class="published">' + this.getHtmlAnchor(timeSince(this.timestamp), 'Read this Diary entry') + '</abbr>';
			break;
			
			default:
			break;
		}
		
		
		html = '<div id="mapPost" class="hentry">' + html + '</div>';
		return applyFilters('postGetHtml', html);
	},
	
	
	// Anchor link, with the title of this post
	getHtmlAnchor: function(){ // optional args: anchor contents, anchor title attribute
		var contents = (arguments.length) ? arguments[0] : this.title;
		var titleAttr = (arguments.length > 1) ? arguments[1] : this.title;
		
		var html = '<a href="' + this.link + '" title="' + titleAttr + '">' + contents + '</a>';
		html = applyFilters('postGetHtmlAnchor', html);
		return html;
	}
};





//////////////////////////////////////////////////////////

/* WAYPOINT CLASS - object to contain GPS data and all posts at that location */

function Waypoint(){
	this.first = false;
	this.last = false;
	this.posts = [];
	this.posts.status = 'start';

	if (arguments.length){
		extend(this, arguments[0]);
	}	
	applyFilters('waypoint', this);
}
Waypoint.prototype = {
	// Create GIcon object
	createIcon: function(){
		var icon = {
			iconSize: new GSize(20,32),
			shadow: "images/marker_shadow.png",
			shadowSize: new GSize(37,34),
			iconAnchor: new GPoint(10, 32),
			infoWindowAnchor: new GPoint(20,0),
			image: (!this.last) ? "images/log_marker.png" : "images/current_marker.png"
		};
		icon = applyFilters('waypointCreateIcon', icon, this);
		this.icon = extend(new GIcon(), icon);
	},
	
	
	// Create GMarker object and add onclick event
	createMarker: function(){
		this.createIcon();
		this.marker = new GMarker(new GPoint(this.lng,this.lat), {
			title:this.point + '. ' + this.place,
			icon:this.icon
		});
	
		var waypoint = this;
		GEvent.addListener(this.marker,'click', function(){ waypoint.showPost(); });
		
		this.marker = applyFilters('waypointCreateMarker', this.marker, this);
		mashup.gmap.addOverlay(this.marker);
	},
	
	
	// Get the index number of a post within the 'posts' array. Accepts post object or id
	getPostIndex: function(postIdentifier){
		var id = (typeof postIdentifier == 'object') ? postIdentifier.id : postIdentifier;		
		for (var i=0; i< this.posts.length; i++){
			if (this.posts[i].id == id){
				return i;
			}
		}
		return null;
	},
	
	
	// Get a post object from the 'posts' array. Accepts post object, id or postIndex
	getPost: function(postIdentifier){
		var postIndex;
		
		// postIndex passed (a finite number or number string)
		if (isFinite(postIdentifier)){
			postIndex = (postIdentifier !== -1) ? postIdentifier : this.posts.length -1;
		}
		
		// post object or id
		else {
			postIndex = this.getPostIndex(postIdentifier);
		}
		
		return (postIndex !== null) ? this.posts[postIndex] : null;
	},
	
	
	// Boolean check for an existing post. Accepts id or postIndex
	postExists: function(postIdentifier){
		return (this.getPost(postIdentifier));
	},
	
	
	
	// Show a post on the map. Accepts postIndex or id
	showPost: function(){
		var that = this;
		
		// Default: show element 0; use -1 for last post
		var postIdentifier = (arguments.length) ? arguments[0] : 0;
				
		// Move map centre to this waypoint's marker
		var moved = this.moveMap();	
		var delay = 250;
		var html;		
		
		
		function displayBalloon(html){
			var m = that.marker;
			var status = that.posts.status;
			
			if (moved){
				window.setTimeout(function(){
					if (status == 'start' && that.posts.status == 'loaded'){
						return;
					}
					mashup.balloon.openOnMarker(m,html);
			   }, delay);
			}
			else {
				mashup.balloon.openOnMarker(m,html);
			}
			
			applyFilters('waypointShowPost', postIdentifier, html, this);
		}
		
		switch (this.posts.status){
			// Posts not yet loaded. Load them!	
			case 'start':
			displayBalloon(this.getHtml('loading'));
			return this.loadPosts(postIdentifier);			
			
			case 'loading':
			displayBalloon(this.getHtml('loading'));
			return false;			
			
			case 'failed':
			displayBalloon(this.getHtml('failed'));
			return false;			
			
			case 'loaded':
			var post = this.getPost(postIdentifier);
			
			// Post doesn't exist
			if (!post){
				// Either no posts made at this location, or requested post was not found
				html = (!this.posts.length) ? this.getHtml('no_posts') : this.getHtml('post_not_found');
				displayBalloon(html);
				return false;
			}
			
			// We're good! Show it already...
			displayBalloon(this.getHtml(post));			
			return true;
		}
	},
	
	
	// Constructs HTML for each info window
	getHtml: function(toDisplay){
		var html = '<div id="balloonContents" class="hfeed">';
		
		// Placename
		var placeHeader = this.getPlaceHtml();
		
		
		// Post to be displayed
		if (typeof toDisplay == 'object'){
			var post = toDisplay;
			html += placeHeader;
			html += '<div id="balloonInner" class="' + post.type + '">';
			html += post.getHtml();
			html += '</div>';
			html += this.getNavHtml(post);
		}
		
		else {
			switch (toDisplay){
				case 'loading':
				html += '<h3>Loading data</h3>';
				html += '<p>Please wait a moment...</p>';
				break;				
				
				case 'failed':
				html += '<h3>There was a problem</h3>';	
				html += "<p>We tried to contact the server, but didn't get a response.</p>";
				html += "<p>Would you like to <a href='" + window.location.href + "' title='Refresh the page'>refresh and try again</a>?</p>";
				break;				
				
				case 'no_posts':
				html += placeHeader;
				html += '<p>No posts have been made from this location.</p>';
				html += this.getNavHtml(null);
				break;				
				
				case 'post_not_found':
				html += '<h3>Post Not Found</h3>';
				html += "<p>Sorry, but we couldn't find that post.</p>";
				html += this.getNavHtml(null);
				break;
			}
		}
		
		html += '</div>';
		
		html = applyFilters('waypointGetHtml', html, toDisplay, this);
		return html;
	},
	
	
	// Get HTML for the place header in map balloons.
	getPlaceHtml: function(){
		var html = (this.place) ? '<h3><abbr class="geo" title="' + this.lat + ';' + this.lng + '">' + this.place + '</abbr></h3>' : '';
		
		html = applyFilters('waypointGetPlaceHtml', html, this);
		return html;
	},
	
	
	
	
	// Get HTML for the navigation through the posts ('Next/Previous'). Accepts post object, postIndex or post id
	getNavHtml: function(postIdentifier){
		var post = this.getPost(postIdentifier);
		var postIndex = this.getPostIndex(post);
		
		var current = {
			waypoint: this.point -1,
			index: postIndex,
			text: (postIndex +1) + ' of ' + this.posts.length
		};
		
		var prev = {
			waypoint: current.waypoint,
			index: current.index -1,
			text: '&laquo; Previous',
			title: '&laquo; Previous post'
		};
		
		var next = {
			waypoint: current.waypoint,
			index: current.index +1,
			text: 'Next &raquo;',
			title: 'Next post &raquo;'
		};
		
		var prevItem = '';
		var nextItem = '';
		
		
		// If only one post in this waypoint, or post non-existant, don't show current
		if (this.posts.length == 1 || !post){
			current.text = '';
		}
		
		
		// If there's no previous post in this waypoint
		if (prev.index == -1 || !this.postExists(prev.index)){

			// Show previous marker
			if (mashup.waypointExists(current.waypoint -1)){
				prev = {
					waypoint: current.waypoint -1,
					index: -1,
					text: '&laquo; Previous location',
					title: '&laquo; Previous location'
				};
			}
			// Start of route
			else {
				prev = null; 
			}
		}
		
			
		// If there's no next post in this waypoint
		if (!this.postExists(next.index)){
			// Show next marker
			if (mashup.waypointExists(current.waypoint +1)){
				next = {
					waypoint: current.waypoint +1,
					index: 0,
					text: 'Next location &raquo;',
					title: 'Next location &raquo;'
				};
			}
			// End of route
			else {
				next = null;
			}
		}
		
		// List items, containing anchors
		if (prev){
			prevItem = '<li class="prev"><a onclick="mashup.showPost(' + prev.waypoint + ',' + prev.index + ');" title="' + prev.title + '">' + prev.text + '</a></li>';
		}
		
		if (next){
			nextItem = '<li class="next"><a onclick="mashup.showPost(' + next.waypoint + ',' + next.index + ');" title="' + next.title + '">' + next.text + '</a></li>';
		}
			
		
		// Construct HTML 
		var html = '<ul class="mapPostNav">';
		html += prevItem + nextItem;	
		html += '<li class="current">' + current.text + '</li>';
		html += '</ul>';
		
		html = applyFilters('waypointGetNavHtml', html, this);
		return html;
	},
	
	
	// Add a single post to the 'posts' array. Accepts post object to add
	addPost: function(post){		
		// Check that post is valid
		if (Post.isValid(post)){
			// Add JavaScript Date object
			post.dateObj = new Date(post.timestamp * 1000);
			
			// Convert Flickr photos to small size
			if (post.type == 'flickr'){
				post.src = post.src.replace(/_s\.jpg$/, '_m.jpg');
			}
			
			post = new Post(post);
			post = applyFilters('waypointAddPost', post, this);				
			return this.posts.push(post);
		}
		return false;
	},
	
	
	// Add an array of posts to the 'posts' array. Accepts array of post objects to add
	addPosts: function(posts){
		for (var i=0; i<posts.length; i++){
			this.addPost(posts[i]);
		}		
		sortBy(this.posts, 'timestamp');
		this.posts = applyFilters('waypointAddPosts', this.posts, this);
		return this.posts;
	},
	
	
	
	// Cache images in the dom, ready to be displayed.
	cacheImages: function(){
		for (var i=0; i<this.posts.length; i++){
			var post = this.posts[i];
			if (post.type == 'flickr'){
				cacheImage(post.src);
			}
		}
	},
	
	
	// Move map to new position when opening an info window on a marker
	moveMap: function(){	
		if (!settings.map.custom_infowindow){
			return null;
		}
		
		var m = mashup.gmap;
		var mkll = this.marker.getLatLng();
		var northLat = m.getBounds().getNorthEast().lat();
		var centerLat = m.getCenter().lat();
		// Quarter height of map = distance from map centre to display marker
		var markerOffset = (northLat - centerLat) /2;
		// Map to move to this LatLng
		var destPos = new GLatLng(mkll.lat() + markerOffset, mkll.lng());
		
		// If marker is within view and within no. of pixels from map centre, then return
		if (m.getBounds().containsLatLng(this.marker.getLatLng()) && withinPixelBuffer(destPos, m.getCenter(), 20)){
			return false;
		}
		// Else move to destination LatLng
		else {			
			mashup.gmap.panTo(destPos);
			return true;
		}
	},
	
	
	// Url to retrieve posts JSON object
	getJsonUrl: function(){
		var url = settings.paths.api_url_base + 'all___';		
		for (var i=0; i < settings.postTypes.length; i++){
			url += settings.postTypes[i].type;			
			if (i < settings.postTypes.length -1){
				url += '-';
			}
		}		
		url += '_' + this.point + '____.json?nocache=' + new Date().getTime();	
		url = applyFilters('waypointGetJsonUrl', url, this);
		return url;
	},
	
	
	// Load posts for the waypoint. Optional arg: showPost (false, or a post object, id or postIndex to show)
	loadPosts: function(){
		var that = this;		
		this.posts.status = 'loading';
		var showPost = (arguments.length>0) ? arguments[0] : false;
		
		function loadPostsCallback(json, status){
			/* Failed! */
			if (status != 200){
				if (typeof that.loadPosts.failed == 'undefined'){
					that.loadPosts.failed = 0;
				}
				
				that.loadPosts.failed ++;
				
				// Try again
				if (that.loadPosts.failed > settings.ajax.attempts_num){
					window.setTimeout(function(){
						return that.loadPosts(showPost);
					}, settings.ajax.attempts_wait);
				}
				
				// Give up
				that.posts.status = 'failed';
				mashup.balloon.openOnMarker(that.marker,that.getHtml('failed'));
				applyFilters('waypointLoadPostsFail', json, status);
				return false;
			}
			
			/* Success! */
			that.posts.status = 'loaded';
			
			// Evaluate Ajax response and add to posts array
			var response = eval('(' + json + ')');
			var items = applyFilters('waypointLoadPostsSuccess', response.items);
			that.addPosts(items);
			
			// Display posts
			if (showPost !== false){
				that.showPost(showPost);
			}
			
			// Cache images in posts - wait for current post image to display
			if (settings.imageCaching){
				window.setTimeout(function(){
					that.cacheImages();
				}, 50);
			}
		}
		
		var callback = applyFilters('waypointLoadPostsCallback', loadPostsCallback);
		GDownloadUrl(this.getJsonUrl(), callback);	
	}
};




//////////////////////////////////////////////////////////

/* MASHUP CLASS - main object class; contains map, waypoints and posts */

function Mashup(map_id){
	this.map_id = map_id;
	this.init();
}
Mashup.prototype = {
	init: function(){
		// Check browser compatability
		function browserIsCompatible(){
			if (!document.getElementById || !document.getElementsByTagName){
				return false;
			}
			else if (!GBrowserIsCompatible()){
				window.alert('Your browser is unable to support this interactive map. You will need to upgrade to a more modern browser.');
				return false;
			}
			return true;
		}
		
				// Add 'Loading Map' report to map
		function addLoadingReport(){
			var loadingText = 'Loading Map...';
			var info = extend(cE('div'), {
				id: 'mapLoading'
			});
			info.appendChild(cTN(loadingText));
			var c = this.getContainer();
			c.insertBefore(info, c.firstChild);
		}
		
		// Check browser compatability
		if (!browserIsCompatible()){
			return false;
		}
				
		// Unload window cleanup
		addListener(window, 'onunload', this.unload);
		
		// Extend with default settings
		extend(this, settings.map.defaults);
		extend(this, {
			// Google Map object
			gmap: extend(new GMap2(gEBI(this.map_id)), {
				maxzoom: this.maxzoom,
				minzoom: this.minzoom
			}),
			location: new GLatLng(this.lat,this.lng),
			waypoints: []
		});
		
		// Add loading report
		GEvent.addListener(this.gmap, "load", addLoadingReport);
		
		// Set center and maptype
		this.gmap.setCenter(this.location, this.zoom, this.maptype);
		
		// Add map controls
		this.gmap.addControl(new GSmallZoomControl());
		this.gmap.addControl(new RecentreControl());
		this.gmap.addControl(new PlacenamesControl());
		this.gmap.addControl(
			new GOverviewMapControl(new GSize(80,80)),
			new GControlPosition(G_ANCHOR_BOTTOM_RIGHT, new GSize(0,0))
		);
		
		// Add custom map balloon
		this.balloon = new CustomBalloon(this.gmap);		
		
		// Draw the proposed route
		this.drawRoute(settings.map.routes.proposed);		
		
		// Retrieve GPS waypoints
		this.getWaypoints();
		
		applyFilters('mashupInit', this);
	},
	
	
	// Function called when all waypoints successfully loaded
	onload: function(){
		this.showPost(-1,-1);
		applyFilters('mashupOnload');
	},
	
	
	// Unload Google Map from memory
	onunload: function(){
		GUnload();
		applyFilters('mashupOnunload');
	},
	
	
	// Draw Polyline on the map - requires object containing waypoints array, colour (upper-case hex), width (pixels), opacity (decimal between 0 and 1)
	drawRoute: function(route){
		var w = route.waypoints;
		var gLatlngs = [];
		for(var i=0; i<w.length; i++){
			gLatlngs.push(new GLatLng(parseFloat(w[i].lat), parseFloat(w[i].lng)));
		}
		var routeline = new GPolyline(gLatlngs, route.color, route.width, route.opacity);		
		routeline = applyFilters('drawRoutePrepare', routeline);
		
		this.gmap.addOverlay(routeline);		
		applyFilters('drawRoute', routeline);
	},
	
	
	
	// Recentre map on the default view
	recentre: function(){
		this.lat = settings.map.defaults.lat;
		this.lng = settings.map.defaults.lng;
		this.zoom = settings.map.defaults.zoom;
		this.gmap.closeInfoWindow();
		this.location = new GLatLng(this.lat,this.lng);
		this.gmap.setCenter(this.location,this.zoom);
		applyFilters('recentre');
	},
	
	
	
	
	// Get GPS waypoints. Draws actual route taken as a Polyline
	getWaypoints: function(){
		var that = this;
	
		// Ajax callback function
		function gpsCallback(json, status){
			// On fail
			if (status != 200){
				// Display fail message
				if (!that.waypoints.length){
				window.setTimeout(function(){				
					var marker = new GMarker(that.location);
					that.gmap.addOverlay(marker);
					that.balloon.openOnMarker(marker, '<div id="balloonContents">' + Waypoint.prototype.getHtml('failed') + '</div>');}, 50);
				}
				applyFilters('gpsFail', json, status);
				return false;
			}
			
			
			// Successful reponse - evaluate the JSON & sort
			var response = eval('(' + json + ')');
			var items = applyFilters('gpsSuccess', response.items);
			
			// If waypoints exist, add to waypoints array
			if (items.length){
				that.addWaypoints(items);
				
				that.drawRoute(extend({
					waypoints:that.waypoints
				}, settings.map.routes.actual));
				
				// Call mashup onload function
				window.setTimeout(function(){
					that.onload();
				}, 1);
			}

			
			// No waypoints yet logged. Show 'coming soon' message
			else{
				window.setTimeout(function(){				
					var marker = new GMarker(that.location);
					that.gmap.addOverlay(marker);
					that.balloon.openOnMarker(marker, '<div id="balloonContents"><h3>Coming Soon</h3><p>All our posts will appear here, on the map.</p></div>');}, 1);
			}
		}
		
		// Ajax download of GPS waypoints
		var callback = applyFilters('gpsCallback', gpsCallback);
		GDownloadUrl(settings.paths.gps_url, callback);
	},
	
	
	
	// Add a waypoint to the waypoints array and create a marker
	addWaypoint: function(waypoint){
		this.waypoints.push(waypoint);	
		waypoint.createMarker();
		waypoint = applyFilters('addWaypoint', waypoint);		
	},
	
	
	// Add array of waypoints to the waypoints array and create a marker for each
	addWaypoints: function(items){
		if (items.length){
			items = sortBy(items, 'timestamp');
			items[0].first = true;
			items[items.length-1].last = true;
			
			items = applyFilters('addWaypoints', items);
		
			for (var i=0; i<items.length; i++){	
				this.addWaypoint(new Waypoint(items[i]));
			}
			return true;
		}
		return false;
	},
	
	
	// Show a post contained within a waypoint
	showPost: function(){
		// Default: show waypoint 0, post 0; use -1 for last waypoint or post		
		var waypointIndex = (arguments.length) ? arguments[0] : 0;
		if (waypointIndex === -1){
			waypointIndex = this.waypoints.length -1;
		}
		
		// Waypoint doesn't exist
		if (!this.waypointExists(waypointIndex)){
			return false;
		}
		
		// Waypoint exists		
		var postIndex = (arguments.length > 1) ? arguments[1] : 0;
		if (postIndex === -1){
			postIndex = this.waypoints[waypointIndex].posts.length -1;
		}
		var response = this.waypoints[waypointIndex].showPost(postIndex);
		return applyFilters('mashupShowPost', response);
	},
	
	
	// Boolean check for an existing waypoint
	waypointExists: function(waypointIndex){
		return typeof this.waypoints[waypointIndex] != 'undefined';
	},
	
	// Turn placenames on and off
	togglePlacenames: function(state){
		arguments.callee.state = state;
		var mapType = (state) ? G_HYBRID_MAP : G_SATELLITE_MAP;
		
		mapType = applyFilters('togglePlacenames', mapType, state);
		this.gmap.setMapType(mapType);
	}
};
Mashup.prototype.togglePlacenames.state = 0;






//////////////////////////////////////////////////////////


/* MAP CONTROLS */

// Generic Toggle control
function ToggleControl(init){
	if (init.title){
		this.title = init.title;
	}
	
	this.src = init.src;
	this.processor = init.processor;
	
	this.getDefaultPosition = function() {
		return init.position;
	};
	
	if (init.state){
		this.state = init.state;
	}	
	applyFilters('toggleControl', this);
}

ToggleControl.prototype = extend(new GControl(),{
	state: 0,
	
	getNextState: function(){
		return (this.state) ? 0 : 1;
	},
	
	update: function(){
		if (arguments.length){
			this.state = arguments[0];
		}
		
		var title_base = ['Turn OFF', 'Turn ON'];
		var nextState = this.getNextState();
		
		extend(this.button, {
			rel: (nextState) ? 'on' : 'off',
			title: title_base[nextState] + ' ' + this.title
		});
		
		extend(this.icon, {
			alt: title_base[nextState] + ' ' + this.title
		});
	},
											  
	initialize: function(map){
		var that = this;
		
		this.button = extend(cE("a"), {
			className: "mapButton"
		});
				
		this.icon = this.button.appendChild(extend(cE('img'), {
			className:'mapButtonIcon',
			src: this.src
		}));
		
		this.update();
	
		GEvent.addDomListener(this.button, "click", function() {
			that.update(that.getNextState());
			that.processor();
		});
		
		return map.getContainer().appendChild(this.button);
	}
});


// Recentre map to default position
function RecentreControl(){
}

RecentreControl.prototype = extend(new GControl(),{
	initialize: function(map) {
		this.button = extend(cE("a"), {
			className: "mapButton",
			title:'Re-Centre Map'
		});
		
		this.icon = this.button.appendChild(extend(cE('img'), {
			className:'mapButtonIcon',
			src:'images/map_recentre.png',
			alt:'Re-Centre Map'
		}));
	
		GEvent.addDomListener(this.button, "click", function() {
			mashup.recentre();
		});
		
		return map.getContainer().appendChild(this.button);
	},
	
	getDefaultPosition: function() {
		return new GControlPosition(G_ANCHOR_TOP_LEFT, new GSize(7, 43));
	}
});



// Toggle place names
function PlacenamesControl(){
}
PlacenamesControl.prototype = new ToggleControl({
	title: 'place names',
	src: 'images/map_placenames.png',
	processor: function(){ mashup.togglePlacenames(this.state); },
	position: new GControlPosition(G_ANCHOR_TOP_LEFT, new GSize(7, 72)),
	state: 1
});




//////////////////////////////////////////////////////////

/* CUSTOMBALLOON - out-of-the-box map balloons */

// Inspired by Mike Williams' EBubble class.
// Removes the infoWindow balloon div from Google Maps DOM, places in body with absolute position, and our own callback to place window correctly.
function CustomBalloon(map) {
	var that = this;
	
	this.gmap = map;
	this.visible = false;
	this.closed = true;
	this.infoWindow = this.gmap.getInfoWindow();

	// Set global settings.map.custom_infowindow to false for default GMaps behavior
	if (settings.map.custom_infowindow) {
		GEvent.addListener(this.gmap, "infowindowopen", function() {
			that.closed = false;
			
			if (that.grabbed) {
				// infoWindow opened off-screen -> move to correct location
				that.setPosition();
				if (!that.visible){
					that.show();
				}
			} else {
				// wait until first infoWindow initialized -> grab balloon element
				that.grabGMapInfoWindow();
			}
		});
		
		GEvent.addListener(this.gmap, "infowindowclose", function(){
			that.closed = true;
		});

		// Reposition infoWindow on move
		GEvent.addListener(this.gmap, "move", function() {
			var m = that.gmap;
			var ne = m.getBounds().getNorthEast();
			var sw = m.getBounds().getSouthWest();
						
			if (!that.latLng){
				return;
			}
			
			// If marker is close to map edge and balloon is open
			else if (that.mapBufferContainsMarker(20) && that.isOpen()){
				// If balloon is already visible then set position
				if (that.visible){
					that.setPosition();
				}
				
				// If not visible, only show it if it is within actual map bounds
				else if (that.mapBufferContainsMarker(0)){
					that.setPosition();
					that.show();
				}
			}
			
			// Otherwise, if it's visible, then hide it
			else if (that.visible){
				that.hide();
			}
		});		
		
		
		// Open infowindow off-screen, so that infoWindow DOM is initialized
		// (problematic when 90,0 is in view)
		this.gmap.openInfoWindowHtml(new GLatLng(90,0), "<div></div>", {	
			suppressMapPan:true
		});
	}
	applyFilters('customBalloon', this);
} 

CustomBalloon.prototype = {
	grabGMapInfoWindow: function() {
		var that = this;
		
		this.balloon = this.infoWindow[settings.map.gmElements.infoWindow.balloon].parentNode;
		this.balloon.parentNode.removeChild(this.balloon);		
		this.balloonShadow = this.infoWindow[settings.map.gmElements.infoWindow.shadow].parentNode;
		this.balloonShadow.parentNode.removeChild(this.balloonShadow);
		
		this.newBalloon = document.body.appendChild(extend(cE('div'),{
			id: 'balloon'
		}));
		this.newBalloon.appendChild(this.balloon);
		
		this.hide();
		
		this.infoWindow.hide = function() {
			that.hide();
		};
		this.grabbed = true;
		applyFilters('customBalloonGrabGMapInfoWindow', this);
	},
	
	
	// open an info window on a marker      
	openOnMarker: function(marker,html) {
		if (!settings.map.custom_infowindow){
			marker.openInfoWindowHtml(html, {
				suppressMapPan:false
			});
		}
			
		else{
			var iconAnchor = marker.getIcon().iconAnchor;
			var infoWindowAnchor = marker.getIcon().infoWindowAnchor;			
			var vx = iconAnchor.x - infoWindowAnchor.x;
			var vy = iconAnchor.y - infoWindowAnchor.y;
			
			this.marker = marker;
			this.openOnMap(this.marker.getLatLng(), html, new GPoint(vx,vy));
		} 
		applyFilters('customBalloonOpenOnMarker', this);
	},

	openOnMap: function(latLng, html, offset) {
		this.offset = offset||new GPoint(0,0);
		this.latLng = latLng;
	
		this.gmap.openInfoWindowHtml(new GLatLng(90,0), html, {
			suppressMapPan: true
		});
		this.show();
		applyFilters('customBalloonOpenOnMap', this);
	},
	
	
	setPosition: function() {
		var x; var y; var a;
		//L[0] = div containing icon; L[1] is shadow
		a = findPos(this.marker[settings.map.gmElements.marker.icon_imgs][0]);
		x = a[0];
		y = a[1];
		
		// balloon.firstChild.lastChild is an image stretched to cover
		// the entire visible balloon, so it's the place to get width/height
		// arrowx, arrowy are offsets for "arrow" portion of the balloon 
		var arrowx = -25;
		var arrowy = 70;
		x -= parseInt(this.balloon.firstChild.lastChild.style.width,10)/2 + arrowx;
		y -= parseInt(this.balloon.firstChild.lastChild.style.height,10) + arrowy;
	
		// offset by the specified offset position
		x -= this.offset.x;
		y -= this.offset.y;
	
		this.balloon.firstChild.style.left = x + "px";
		this.balloon.firstChild.style.top = y +"px";
		
		applyFilters('customBalloonSetPosition', this);
	},
	
	// Determine if a latLng is within a specified distance (buffer /px) around the map bounds
	mapBufferContainsMarker: function(buffer){
		var m = this.gmap;
		var p = m.getCurrentMapType().getProjection();
		var latLngBounds = m.getBounds();
		var targetlatLng = this.latLng;
		var iconHeight = this.marker.getIcon().iconSize.height;
		
		var ne = latLngBounds.getNorthEast();
		var sw = latLngBounds.getSouthWest();
		var nePx = p.fromLatLngToPixel(ne, m.getZoom());
		var swPx = p.fromLatLngToPixel(sw, m.getZoom());
		var targetPx = p.fromLatLngToPixel(targetlatLng, m.getZoom());	
		
		var biggerBounds = new GBounds([new GPoint(Math.min(swPx.x, nePx.x) - buffer, Math.min(nePx.y, swPx.y) - buffer + iconHeight), new GPoint(Math.max(swPx.x, nePx.x) + buffer, Math.max(nePx.y, swPx.y) + buffer + iconHeight)]);
		return biggerBounds.containsPoint(targetPx);
	},
	
	show: function() {
		this.visible = true;
		this.newBalloon.style.display = "block";
		applyFilters('customBalloonShow', this);
	},
	
		  
	hide: function() {
		this.visible = false;
		this.newBalloon.style.display = "none";
		applyFilters('customBalloonHide', this);
	},
		  
	isHidden: function() {
		return !this.visible;
	},
		  
	supportsHide: function() {
		return true;
	},
	
	isOpen: function(){
		return !this.closed;
	}
};




//////////////////////////////////////////////////////////

/* BASIC FUNCTIONS */



// Extend one object with properties of another. Optional arg: includeInheritedProps (boolean), default: false
function extend(dest, source){
	var includeInheritedProps = (arguments.length > 2) ? arguments[2] : false;
	for (var prop in source){
		if (includeInheritedProps || source.hasOwnProperty(prop)){
			dest[prop] = source[prop];
		}
	}
	return dest;
}

// Dom Shortcuts
function cE(nodeType){
	return document.createElement(nodeType);
}
function cTN(text){
	return document.createTextNode(text);
}
function gEBI(id){
	return document.getElementById(id);
}


// Get array of elements by an arbitrary property
// optional args: parentNode (node), singleResult (boolean)
function getElementsBy(attr, val){
	var parentNode = (arguments.length > 2) ? arguments[2] : document.body;
	var singleResult = (arguments.length > 3) ? arguments[3] : false;
	if (!parentNode.getElementsByTagName){
		return false;
	}
	var elements = [];
	var nodes = parentNode.getElementsByTagName('*');
	for (var i=0; i<nodes.length; i++){
		if (typeof nodes[i][attr] == 'string'){
			if (nodes[i][attr].match(new RegExp("(^|\\s)" + val + "(\\s|$)"))){
				elements.push(nodes[i]);
				if (singleResult){
					break;
				}
			}
		}
	}
	return elements;
}


// Get text content of a node
function getTextContent(node){
	var text = '';	
	for (var i=0; i<node.childNodes.length; i++){
		var c = node.childNodes[i];
		switch (c.nodeType){
			case 1: // element
			text += getTextContent(c);
			break;
			
			case 3: // text node
			text += c.nodeValue;
			break;
		}
	}
	return text;
}




// Trim a string
function trim(str) {
    return str.replace(/^\s+|\s+$/g, '');
}




// Position of an object on screen
function findPos(obj) {
	var curleft = 0;
	var curtop = 0;
	if (obj.offsetParent) {
		curleft = obj.offsetLeft;
		curtop = obj.offsetTop;
		while ((obj = obj.offsetParent)) {
			curleft += obj.offsetLeft;
			curtop += obj.offsetTop;
		}
	}
	return [curleft, curtop];
}


// Measure if one GLatLng is mapped within a certain number of pixels from another.
// ll1: first position (GLatLng), ll2: second position (GLatLng), buffer: within pixels distance (Number)
function withinPixelBuffer(ll1, ll2, buffer){
	var m = mashup.gmap;
	var p = m.getCurrentMapType().getProjection();
	var pxll1 = p.fromLatLngToPixel(ll1, m.getZoom());
	var pxll2 = p.fromLatLngToPixel(ll2, m.getZoom());
	return (Math.abs(pxll1.x - pxll2.x) <= buffer && Math.abs(pxll1.y - pxll2.y) <= buffer);
}


	
// Sort an array of objects by an arbitrary property
function sortBy(array, prop){
	function sortFunc(a, b){
		if (typeof a[prop] == 'undefined' || typeof b[prop] == 'undefined'){
			return 0;
		}		
		if(a[prop] > b[prop]){
			return 1;
		}
		if(a[prop] < b[prop]){
			return -1;
		}
		return 0;
	}	
	return array.sort(sortFunc);
}




// Cache an image to the dom
function cacheImage(src){
	var mapCache = gEBI('mapCache');
	if (!mapCache){
		mapCache = gEBI(mashup.map_id).appendChild(extend(cE('div'),{
			id: 'mapCache'
		}));
	}
	
	return mapCache.appendChild(extend(cE('img'),{
		src: src,
		alt: '',
		onload: function(){
			var that = this;
			window.setTimeout(function(){
				that.src = '';
				that.parentNode.removeChild(that);
			}, 1);
		}
	}));
}




// Parse an atom date and return as a JS Date object. E.g. '2007-10-29T23:39:38+06:00'
function parseAtomDate(atomDate){
    // Convert 'Z' UTC to '+00:00' and split to array
    atomDate = atomDate.replace(/z$/i, '+00:00');
    var d = atomDate.split(/[\-T:+]/);
	if (d.length != 8){
		return false;
	}
    // Timezone + / -
	var plusminus = atomDate.substr(19,1);
	
	var date = {
		year: Number(d[0]),
		month: Number(d[1] - 1),
		day: Number(d[2]),
		hours: Number(d[3] - Number(plusminus + d[6])),
		minutes: Number(d[4] - Number(plusminus + d[7])),
		seconds: Number(d[5])
	};
	
    return new Date(Date.UTC(date.year, date.month, date.day, date.hours, date.minutes, date.seconds));
}



// Convert timestamp to a 'time since' string. Arg: in seconds
function timeSince(timestamp){
	var now = new Date().getTime() / 1000;
	var diff = (now - timestamp) / 60; // in mins
	
	if (diff < 1) {
		return 'just now';
	} 
	else if (diff < 2) {
		return 'just a minute ago';
	} 
	else if (diff < (45)) {
		return (Math.floor(diff)) + ' mins ago';
	} 
	else if (diff < (90)) {
		return 'about an hour ago';
	} 
	else if (diff < (120)) {
		return 'a couple of hours ago';
	} 
	else if (diff < (60*24)) {
		return 'about ' + (Math.floor(diff / 60)) + ' hours ago';
	} 
	else if (diff < (60*24*2)) {
		return 'yesterday';
	} 
	else if (diff < (60*24*7)){
		return (Math.floor(diff / (60*24))) + ' days ago';
	} 
	else if (diff < (60*24*7*2)) {
		return 'last week';
	} 
	else if (diff < (60*24*7*9)) {
		return (Math.floor(diff / (60*24*7))) + ' weeks ago';
	}
	else if (diff < (60*24*365)) {
		return (Math.floor(diff / (60*24*30.5))) + ' months ago';
	} 
	else if (diff < (60*24*730)) {
		return 'last year';
	} 
	else {
		return (Math.floor(diff / (60*24*365))) + ' years ago';
	}
}





// Get the browser query string as an object
function getQueryString(){
	var s = String(window.location.search).replace(/^\?/, '').split("&");
	var searchObj = {};
	for (var i=0; i<s.length; i++){
		s[i] = s[i].split('=');
		if (s[i].length == 2){
			if (s[i][0] !== ''){
				searchObj[s[i][0]] = s[i][1];
			}
		}
	}
	return searchObj;
}




// Insert CSS rules into a new inline stylesheet - supply either a single rule as a string or an array of strings
function insertStyles(styles){
	// If array supplied, convert to string
	if (typeof styles == 'object'){
		styles = styles.join(' ');
	}
		
	var head = document.getElementsByTagName('head')[0];
	var style = head.appendChild(extend(cE('style'), {
		type: 'text/css'
	}));
	
	if (style.styleSheet){ // IE
		style.styleSheet.cssText = styles;
	}
	
	else {// w3c
		style.appendChild(cTN(styles));
	}
}



// Add a listener function to an event - e.g. window.onload
function addListener(obj, eventname, func){
	eventname = 'on' + eventname;
	if(typeof obj[eventname] != 'function'){
		obj[eventname] = func;
	}
	else {
		var oldevent = obj[eventname];
		obj[eventname] = function(){
			oldevent();
			func();
		};
	}
}



// Extend key events with extra functionality. Useful for script extensions, e.g. Greasemonkey API. E.g. applyFilters('postHtml', html);
function applyFiltersInit(){
	var filters = {};
	
	applyFilters = function(filterName){
		var payload = (arguments.length > 1) ? arguments[1] : null;
	
		var args = [];
		for (var i=1; i<arguments.length; i++){
			args.push(arguments[i]);
		}
		var f = filters[filterName];
		if (f){
			for (i=0; i<f.length; i++){
				payload = f[i].func.apply(null, args);
				args.shift();
				args.unshift(payload);
			}
		}	
		return payload;
	};
	
	addFilter = function(filterName, func){
		var priority = (arguments.length > 2) ? arguments[2] : 0;
		if (typeof filters[filterName] == 'undefined'){
			filters[filterName] = [];
		}
		var f = filters[filterName];
		f.push({
			func:func,
			priority:priority
		});
		f = sortBy(f, 'priority');
	};
}



//////////////////////////////////////////////////////////


/* HIGHER LEVEL FUNCTIONS */



// Update times on HTML posts
function updateHtmlTimeSince(){
	var parentNode = (arguments.length) ? arguments[0] : document.body;	
	var nodes = getElementsBy('className', 'published', parentNode);
	nodes = applyFilters('updateHtmlTimeSince', nodes);
	
	for (var i=0; i<nodes.length; i++){
		var atomDate = trim(nodes[i].title);
		var newTimeSince = timeSince(parseAtomDate(atomDate).valueOf()/1000);
		try {
			nodes[i].replaceChild(cTN(newTimeSince), nodes[i].firstChild);
		}
		catch(e){} // IE6 doesn't do <abbr>'s
	}
}


// Activate anchors in HTML posts to open on map
function activateHtmlPosts(){	
	// Function to add to anchor onclick event - shows the post on the map
	function showOnMap(waypointIndex, id){
		return function(){
			mashup.showPost(waypointIndex, id);
			return false;
		};	
	}
	
	function getId(postNode){
		return postNode.className.replace(/^.*postid-(\w*).*?$/, '$1');
	}
	
	function getGeo(postNode){
		var nodes = getElementsBy('className', 'geo', postNode, true);
		if (!nodes.length){
			return false;
		}
		var n = nodes[0];
		
		return {
			lat: n.title.split(';')[0],
			lng: n.title.split(';')[1],
			place: trim(getTextContent(n)),
			point: n.className.replace(/^.*point-(\d*).*?$/, '$1')
		};
	}
	
	function getLink(postNode){
		var nodes = getElementsBy('className', 'taggedlink', postNode, true);
		return (nodes.length) ? nodes[0].href : '';
	}
		
	function getAtomDate(postNode){
		var nodes = getElementsBy('className', 'published', postNode, true);
		return (nodes.length) ? nodes[0].title : '';
	}
	
	function getTitle(postNode){
		var nodes = getElementsBy('className', 'entry-title', postNode, true);
		return (nodes.length) ? trim(getTextContent(nodes[0])) : '';
	}	
	
	function getDescription(postNode){
		var nodes = getElementsBy('className', 'entry-content', postNode, true);
		return (nodes.length) ? trim(getTextContent(nodes[0])) : '';
	}
	
	function activateAnchors(post){	
		var anchors = getElementsBy('rel', 'bookmark', post, true);
		var geo = getGeo(post);
		var id = getId(post);
		var nodes;
		
		var activateFunc = applyFilters('activateHtmlPostsFunc', showOnMap);
		
		if (anchors.length && geo && id){
			anchors[0].onclick = activateFunc(geo.point-1, id);
			
			// Check if post is a Flickr photo - if so, activate its title
			if (post.className.match(new RegExp("postid-f"))){
				nodes = getElementsBy('className', 'entry-title', post, true);
				if (nodes.length){
					var altAnchors = nodes[0].getElementsByTagName('a');
					if (altAnchors.length){
						altAnchors[0].onclick = anchors[0].onclick;
					}
				}
			}
			
			// Check if post is a 'leader' entry - if so, activate its photo
			if (post.className.match(new RegExp("(^|\\s)leader(\\s|$)"))){
				nodes = getElementsBy('className', 'leaderImage', post, true);
				if (nodes.length){
					nodes[0].onclick = anchors[0].onclick;
				}
			}
		}
	}
	
	//	
	
	var parentNode = (arguments.length) ? arguments[0] : document.body;	
	var posts = getElementsBy('className', 'hentry', parentNode);
	posts = applyFilters('activateHtmlPostsPosts', posts);
	
	for (var i=0; i<posts.length; i++){
		activateAnchors(posts[i]);
	}
}




//////////////////////////////////////////////////////////

/* SCRIPT SPECIFICS */


// Process web address query string
function processQueryString(){
	var s = getQueryString();
	if (typeof s.display != 'undefined'){
		if (s.display === 'full'){
			gEBI(settings.dom.mashup_id).className = 'full';
		}
	}
	applyFilters('processQueryString', s);
}




// Initialise script
function scriptInit(){
	// Initialise applyFilters for script extensions
	applyFiltersInit();
	
	// Import mashup CSS styles
	insertStyles('@import url(' + settings.dom.mashup_css + ');');
	
	// Set up mashup when DOM loads
	addListener(window, 'load', onDomLoaded);
}




// Set up mashup when DOM loaded
function onDomLoaded(){
	applyFilters('onDomLoadedStart');
	
	// Process query string
	processQueryString();
	
	// Update the 'time since' text on posts in the HTML
	updateHtmlTimeSince(gEBI(settings.dom.mashup_id));

	// Initialise new mashup object
	mashup = new Mashup(settings.dom.map_id);
	if (mashup){
		// Activate links on posts in the HTML, so the posts open on the map
		activateHtmlPosts(gEBI(settings.dom.mashup_id));
	}
	
	applyFilters('onDomLoadedEnd');
}