// vim: et:ts=4:sw=4:sts=4:ft=javascript

'use strict';

/*@
Ox.Map <function> Basic map object
    # DESCRIPTION --------------------------------------------------------------
    <code>Ox.Map</code> is a wrapper around the
    <a href="http://code.google.com/apis/maps/documentation/javascript/">Google
    Maps API</a>.
    # USAGE --------------------------------------------------------------------
    () ->              <f> Map object
    (options) ->       <f> Map object
    (options, self) -> <f> Map object
    # ARGUMENTS ----------------------------------------------------------------
    options <o|{}> options
        clickable       <b|false> If true, clicking on the map finds a place
        editable        <b|false> If true, places are editable
        find            <s|""> Initial query
        findPlaceholder <s|"Find"> Placeholder text for the find input element
        maxMarkers      <n|100> Maximum number of markers to be displayed
        places          <[o]|f|null> Array of, or function that returns, place objects
            countryCode     <s> ISO 3166 country code
            east            <n> Longitude of the eastern boundary in degrees
            editable        <b|false> If true, the place is editable
            geoname         <s> Geoname (like "Paris, Île-de-France, France")
            lat             <n> Latitude in degrees
            lng             <n> Longitude in degrees
            markerColor     <s|"red"> CSS color of the place marker
            markerSize      <n|16> size of the place marker in px
            name            <s> Name (like "Paris")
            north           <n> Latitude of the northern boundary in degrees
            south           <n> Latitude of the southern boundary in degrees
            type            <s> Type (like "city" or "country")
            west            <n> Longitude of the western boundary in degrees
        selected        <s|""> Id of the selected place
        showControls    <b|false> If true, show controls
        showLabels      <b|false> If true, show labels on the map
        showTypes       <b|false> If true, color markers according to place type
        statusbar       <b|false> If true, the map has a statusbar
        toolbar         <b|false> If true, the map has a toolbar
        zoombar         <b|false> If true, the map has a zoombar
    self <o|{}> Shared private variable
    # EVENTS -------------------------------------------------------------------
    addplace <!> Fires when a place has been added
        place <o> Place object
    editplace <!> Fires when a place has been edited
        place <o> Place object
    geocode <!> Fires when a google geocode request returns
        latLng <o|u> Query coordinates, or undefined
            lat <n> latitude
            lng <n> longitude
        address <s|u> Query string, or undefined
        results <[o]> Google Maps geocode results
            address_components <[o]> Address components
                long_name <s> Long name
                short_name <s> Short name
                types <[s]> Types (like "country" or "political")
            formatted_address <s> Formatted address
            geometry <o> Geometry
                bounds <o> Bounds
                    northEast <o> North-east corner
                        lat <n> Latitude
                        lng <n> Longitude
                    southWest <o> South-west corner
                        lat <n> Latitude
                        lng <n> Longitude
                location <o> Location
                    lat <n> Latitude
                    lng <n> Longitude
                location_type <s> Location type (like "APPROXIMATE")
                viewport <o> Viewport
                    northEast <o> North-east corner
                        lat <n> Latitude
                        lng <n> Longitude
                    southWest <o> South-west corner
                        lat <n> Latitude
                        lng <n> Longitude
            types <[s]> Types (like "country" or "political")
    selectplace <!> Fires when a place has been selected or deselected
        place <o> Place object
    # EXAMPLES -----------------------------------------------------------------
    <script>
        // simple
        Ox.load('UI', function() {
            Ox.Map().appendTo(Ox.UI.$body);
        });
    </script>
    > Ox.Map() === true
    false
    > Ox.Map() === false
    false
@*/

Ox.Map = function(options, self) {

    self = self || {};
    var that = Ox.Element({}, self)
        .defaults({
            // fixme: isClickable? hasZoombar?
            clickable: false,
			editable: false,
			find: '',
			findPlaceholder: 'Find',
            maxMarkers: 100,
            places: null,
            selected: '',
            showControls: false,
            showLabels: false,
            showTypes: false,
            statusbar: false,
            toolbar: false,
            zoombar: false
            // fixme: width, height
        })
        .options(options || {})
        .addClass('OxMap')
        .click(function(e) {
            !$(e.target).is('input') && that.gainFocus();
        })
		.bindEvent({
			key_0: function() {
			    panToPlace()
			},
			key_c: toggleControls,
            key_down: function() {
				pan(0, 1);
			},
            key_enter: pressEnter,
            key_escape: pressEscape,
			key_equal: function() {
				zoom(1);
			},
			key_l: toggleLabels,
            key_left: function() {
				pan(-1, 0);
			},
			key_meta: function() {
			    self.metaKey = true;
			    $(document).one({
			        keyup: function() {
			            self.metaKey = false;
			        }
			    });
			},
			key_minus: function() {
				zoom(-1);
			},
            key_right: function() {
				pan(1, 0);
			},
			key_shift: function() {
			    self.shiftKey = true;
			    $(document).one({
			        keyup: function() {
			            self.shiftKey = false;
			        }
			    });
			},
			key_shift_down: function() {
			    pan(0, 2);
			},
			key_shift_0: function() {
			    zoomToPlace();
			},
			key_shift_equal: function() {
			    zoom(2);
			},
			key_shift_left: function() {
			    pan(-2, 0);
			},
			key_shift_minus: function() {
			    zoom(-2);
			},
			key_shift_right: function() {
			    pan(2, 0);
			},
			key_shift_up: function() {
			    pan(0, -2);
			},
            key_up: function() {
				pan(0, -1);
			},
			key_z: undo
        });

    self.isAsync = Ox.isFunction(self.options.places);
    self.mapHeight = getMapHeight();
    self.minZoom = getMinZoom();
    self.placeKeys = [
        'id', 'name', 'alternativeNames', 'geoname', 'countryCode', 'type',
        'lat', 'lng', 'south', 'west', 'north', 'east', 'area',
        'editable'
    ];
    self.scaleMeters = [
        50000000, 20000000, 10000000,
        5000000, 2000000, 1000000,
        500000, 200000, 100000,
        50000, 20000, 10000,
        5000, 2000, 1000,
        500, 200, 100,
        50, 20, 10
    ];

    Ox.extend(self, {
        metaKey: false,
        resultPlace: null,
        shiftKey: false
    });

    if (self.options.toolbar) {
        self.$toolbar = Ox.Bar({
                size: 24
            })
            .appendTo(that);
        self.$select = Ox.MenuButton({
                items: Ox.merge(
                    self.options.editable
                        ? [{id: 'new Place', title: 'New Place...', keyboard: 'n'}, {}]
                        : [],
                    [
                        {
                            id: 'toggleLabels',
                            title: self.options.showLabels
                                ? ['Hide Labels', 'Show Labels']
                                : ['Show Labels', 'Hide Labels'],
                            keyboard: 'l'
                        },
                        {
                            id: 'toggleControls',
                            title: self.options.showControls
                                ? ['Hide Controls', 'Show Controls']
                                : ['Show Controls', 'Hide Controls'],
                            keyboard: 'c'
                        }
                    ]
                ),
                title: 'Options...',
                width: 96
            })
            .css({float: 'left', margin: '4px'})
            .bindEvent({
                click: function(data) {
                    if (data.id == 'toggleLabels') {
                        toggleLabels();
                    } else if (data.id == 'toggleControls') {
                        toggleControls();
                    }
                }
            })
            .appendTo(self.$toolbar);
        /*
        self.$labelsButton = Ox.Checkbox({
                title: 'Labels',
                width: 64
            })
            .css({float: 'left', margin: '4px'})
            .bindEvent({
                change: toggleLabels
            })
            .appendTo(self.$toolbar)
        */
        self.$findInput = Ox.Input({
                clear: true,
                placeholder: self.options.findPlaceholder,
                width: 192
            })
            .css({float: 'right', margin: '4px'})
            .bindEvent({
                submit: submitFind
            })
            .appendTo(self.$toolbar)
    }

    self.$map = Ox.Element()
        .css({
            left: 0,
            top: self.options.toolbar * 24 + 'px',
            right: 0,
            bottom: self.options.zoombar * 16 + self.options.statusbar * 24 + 'px'
        })
        .appendTo(that);

    if (self.options.zoombar) {
        self.$zoombar = Ox.Bar({
                size: 16
            })
            .css({
                bottom: self.options.statusbar * 24 + 'px'
            })
            .appendTo(that);
    }

    if (self.options.statusbar) {
        self.$statusbar = Ox.Bar({
                size: 24
            })
            .css({
                bottom: 0
            })
            .appendTo(that);
        self.$placeFlag = $('<img>')
            .addClass('OxFlag')
            .attr({
                src: Ox.PATH + 'Ox.Geo/png/icons/16/NTHH.png'
            })
            .css({float: 'left', margin: '4px 2px 4px 4px'})
            .appendTo(self.$statusbar.$element);
        self.$placeNameInput = Ox.Input({
                placeholder: 'Name',
                width: 96
            })
            //.css({position: 'absolute', left: 4, top: 4})
            .css({float: 'left', margin: '4px 2px 4px 2px'})
            .appendTo(self.$statusbar);
        self.$placeGeonameInput = Ox.Input({
                placeholder: 'Geoname',
                width: 96
            })
            //.css({position: 'absolute', left: 104, top: 4})
            .css({float: 'left', margin: '4px 2px 4px 2px'})
            .appendTo(self.$statusbar);
        self.$placeButton = Ox.Button({
                title: 'New Place',
                width: 96
            })
            .css({float: 'right', margin: '4px 4px 4px 2px'})
            .bindEvent({
                click: clickPlaceButton
            })
            .appendTo(self.$statusbar);
    }

    self.$controls = {
        center: Ox.Button({
                title: 'center',
                type: 'image'
            })
            .addClass('OxMapControl OxMapButtonCenter')
            .bindEvent({
                singleclick: function() {
                    panToPlace();
                },
                doubleclick: function() {
                    zoomToPlace();
                }
            }),
        east: Ox.Button({
                title: 'right',
                type: 'image'
            })
            .addClass('OxMapControl OxMapButtonEast')
            .bindEvent({
                singleclick: function() {
                    pan(1, 0);
                },
                doubleclick: function() {
                    pan(2, 0);
                }
            }),
        north: Ox.Button({
                title: 'up',
                type: 'image'
            })
            .addClass('OxMapControl OxMapButtonNorth')
            .bindEvent({
                singleclick: function() {
                    pan(0, -1);
                },
                doubleclick: function() {
                    pan(0, -2);
                }
            }),
        south: Ox.Button({
                title: 'down',
                type: 'image'
            })
            .addClass('OxMapControl OxMapButtonSouth')
            .bindEvent({
                singleclick: function() {
                    pan(0, 1);
                },
                doubleclick: function() {
                    pan(0, 2);
                }
            }),
        west: Ox.Button({
                title: 'left',
                type: 'image'
            })
            .addClass('OxMapControl OxMapButtonWest')
            .bindEvent({
                singleclick: function() {
                    pan(-1, 0);
                },
                doubleclick: function() {
                    pan(-2, 0);
                }
            }),
        scale: Ox.Label({
                textAlign: 'center'
            })
            .addClass('OxMapControl OxMapScale')
    };
    !self.options.showControls && Ox.forEach(self.$controls, function($control) {
        $control.css({opacity: 0}).hide();
    });

    self.$placeControls = {
        flag: Ox.Element()
            .addClass('OxPlaceControl OxPlaceFlag'),
        name: Ox.Label({
                textAlign: 'center'
            })
            .addClass('OxPlaceControl OxPlaceName')
            .bindEvent({
                singleclick: function() {
                    panToPlace();
                },
                doubleclick: function() {
                    zoomToPlace();
                }
            }),
        deselectButton: Ox.Button({
                title: 'close',
                type: 'image',
            })
            .addClass('OxPlaceControl OxPlaceDeselectButton')
            .bindEvent({
                click: function() {
                    selectPlace(null);
                }
            })
    }
    Ox.forEach(self.$placeControls, function($placeControl) {
        $placeControl.css({opacity: 0}).hide();
    });

    if (!self.isAsync) {
        self.options.places.forEach(function(place) {
            if (Ox.isUndefined(place.id)) {
                place.id = Ox.encodeBase32(Ox.uid());
            }
        });
    }

    if (!window.googleCallback) {
        window.googleCallback = function() {
            delete window.googleCallback;
            initMap();
        };
        $.getScript(document.location.protocol
                    + '//maps.google.com/maps/api/js?callback=googleCallback&sensor=false');
    } else {
        (function interval() {
            window.google ? initMap() : setTimeout(interval, 100);
        }());
    }

    function addPlaceToMap(place) {
        // via find, click, shift-click, or new place button
        Ox.Log('Map', 'addPlaceToMap', place)
        var exists = false;
        if (!place) {
            var bounds = self.map.getBounds(),
                center = self.map.getCenter(),
                southwest = new google.maps.LatLngBounds(
                    bounds.getSouthWest(), center
                ).getCenter(),
                northeast = new google.maps.LatLngBounds(
                    center, bounds.getNorthEast()
                ).getCenter(),
                place = new Ox.MapPlace({
                    alternativeNames: [],
                    countryCode: '',
                    editable: true,
                    geoname: '',
                    id: '_' + Ox.encodeBase32(Ox.uid()), // fixme: stupid
                    map: that,
                    name: '',
                    type: 'feature',
                    south: southwest.lat(),
    		        west: southwest.lng(),
    		        north: northeast.lat(),
    		        east: northeast.lng()
                });
        }
        Ox.forEach(self.places, function(p, i) {
            if (place.bounds.equals(p.bounds)) {
                place = p;
                exists = true;
                return false;
            }
        });
        if (!exists) {
            self.resultPlace && self.resultPlace.remove();
            self.resultPlace = place;
            place.add();
        }
        selectPlace(place.id);
    }

    function addPlaceToPlaces(data) {
        var place = getSelectedPlace(),
            country = Ox.getCountryByGeoname(place.geoname);
        Ox.extend(place, data);
        self.options.selected = place.id;
        place.countryCode = country ? country.code : '';
        Ox.Log('Map', 'addP2P', data, place);
        place.marker.update();
        self.places.push(place);
        self.resultPlace = null;
        that.triggerEvent('addplace', place)
        //Ox.Log('Map', 'SSSS', self.options.selected)
    }

    function boundsChanged() {
        setScale();
        self.boundsChanged = true;
    }

    function canContain(outerBounds, innerBounds) {
        // checks if outerBounds _can_ contain innerBounds
        var outerSpan = outerBounds.toSpan(),
            innerSpan = innerBounds.toSpan();
        return outerSpan.lat() > innerSpan.lat() &&
            outerSpan.lng() > innerSpan.lng();
    }

    function centerChanged() {
        var tooltip = $('.OxMapMarkerTooltip');
        if (tooltip.length) {
            Ox.Log('Map', '??', tooltip[0].className, $(tooltip[0]).data('oxid'));
            Ox.UI.elements[$(tooltip[0]).data('oxid')].hide();
        }
        self.center = self.map.getCenter();
        self.centerChanged = true;
    }

    function changeZoom(data) {
        self.map.setZoom(data.value);
    }

    function clickMap(event) {
        var place = getSelectedPlace();
		if (self.options.clickable/* && !editing()*/) {
	        getPlaceByLatLng(event.latLng, self.map.getBounds(), function(place) {
                if (place) {
                    addPlaceToMap(place);
                    //selectPlace(place.id);
                } else {
                    selectPlace(null);
                }
            });
		} else {
		    pressEscape();
		}
    }

    function clickPlaceButton() {
        var place = getSelectedPlace(),
            title = self.$placeButton.options('title');
        if (title == 'New Place') {
            addPlaceToMap();
        } else if (title == 'Add Place') {
            addPlaceToPlaces();
        }
    }

    function constructZoomInput() {
        //Ox.Log('Map', 'constructZoomInput', self.minZoom, self.maxZoom)
        if (self.options.zoombar) {
            self.$zoomInput && self.$zoomInput.remove();
            self.$zoomInput = Ox.Range({
                    arrows: true,
                    changeOnDrag: true,
                    max: self.maxZoom,
                    min: self.minZoom,
                    size: that.width(),
                    thumbSize: 32,
                    thumbValue: true,
                    value: self.map.getZoom()
                })
                .bindEvent({
                    change: changeZoom
                })
                .appendTo(self.$zoombar);
        }
    }

    function crossesDateline() {
        var bounds = self.map.getBounds();
        return bounds.getSouthWest().lng() > bounds.getNorthEast().lng();
    }

    function editing() {
        var place = getSelectedPlace();
        return place && place.editing;
    }

    function getElevation(point, callback) {
        // fixme: unused
        if (arguments.length == 1) {
            callback = point;
            point = self.map.getCenter();
        }
        self.elevationService.getElevationForLocations({
            locations: [point]
        }, function(data) {
            callback(data.elevation);
        });
    }

    function getMapBounds(callback) {
        // get initial map bounds
        var mapBounds;
        if (!self.isAsync) {
            self.options.places.forEach(function(place, i) {
                var bounds = getBounds(place);
                mapBounds = i == 0 ? bounds : mapBounds.union(bounds);
            });
            callback(mapBounds);
        } else {
            self.options.places({}, function(result) {
                callback(getBounds(result.data.area));
            });
        }
        function getBounds(place) {
            return new google.maps.LatLngBounds(
                new google.maps.LatLng(place.south, place.west),
                new google.maps.LatLng(place.north, place.east)
            );
        }
    }

    function getMapHeight() {
        return self.options.height
            - self.options.statusbar * 24
            - self.options.toolbar * 24
            - self.options.zoombar * 16;
    }

    function getMapType() {
        return self.options.showLabels ? 'HYBRID' : 'SATELLITE'
    }

    function getMaxZoom(point, callback) {
        if (arguments.length == 1) {
            callback = point;
            point = self.map.getCenter();
        }
        self.maxZoomService.getMaxZoomAtLatLng(point, function(data) {
            callback(data.status == 'OK' ? data.zoom : null);
        });
    }

    function getMetersPerPixel() {
        // m/px = m/deg * deg/px
        var degreesPerPixel = 360 / (Ox.MAP_TILE_SIZE * Math.pow(2, self.map.getZoom()));
        return Ox.getMetersPerDegree(self.map.getCenter().lat()) * degreesPerPixel;
    }

    function getMinZoom() {
        return self.mapHeight > 1024 ? 3
            : self.mapHeight > 512 ? 2
            : self.mapHeight > 256 ? 1
            : 0;
        // fixme: there must be a function for this...
        /*
        return Math.ceil(
            Ox.log(self.mapHeight / Ox.MAP_TILE_SIZE, 2)
        );
        */
    }

    function getPlaceById(id) {
        var place = Ox.getObjectById(self.places, id);
        if (!place && self.resultPlace && self.resultPlace.id == id) {
            place = self.resultPlace;
        }
        //Ox.Log('Map', 'getPlaceById', id, place)
        return place;
    }

    function getPlaceByLatLng(latlng, bounds, callback) {
        // gets the largest place at latlng that would fit in bounds
        //Ox.Log('Map', 'll b', latlng, bounds)
        var callback = arguments.length == 3 ? callback : bounds,
            bounds = arguments.length == 3 ? bounds : null;
        self.geocoder.geocode({
            latLng: latlng
        }, function(results, status) {
            //Ox.Log('Map', 'results', results)
            var length = results.length;
            if (status == google.maps.GeocoderStatus.OK) {
                if (bounds) {
                    Ox.forEach(results.reverse(), function(result, i) {
                        if (
                            i == length - 1 ||
                            canContain(bounds, result.geometry.bounds || result.geometry.viewport)
                        ) {
                            callback(new Ox.MapPlace(parseGeodata(results[i])));
                            return false;
                        }
                    });
                } else {
                    callback(new Ox.MapPlace(parseGeodata(results[0])));
                }
            }
            if (
                status == google.maps.GeocoderStatus.OK ||
                status == google.maps.GeocoderStatus.ZERO_RESULTS
            ) {
                triggerGeocodeEvent({
                    latLng: latlng,
                    results: results
                });
            } else {
                Ox.Log('Map', 'geocode failed:', status);
                callback(null);
            }
        });
    }

    function getPlaceByName(name, callback) {
        self.geocoder.geocode({
            address: name
        }, function(results, status) {
            if (status == google.maps.GeocoderStatus.OK) {
                callback(new Ox.MapPlace(parseGeodata(results[0])));
            }
            if (
                status == google.maps.GeocoderStatus.OK
                && status != google.maps.GeocoderStatus.ZERO_RESULTS
            ) {
                triggerGeocodeEvent({
                    address: name,
                    results: results
                });
            } else {
                Ox.Log('Map', 'geocode failed:', status);
                callback(null);
            }
        });
    }

	function getPositionByName(name) {
		var position = -1;
		Ox.forEach(self.options.places, function(place, i) {
			if (place.name == name) {
				position = i;
				return false;
			}
		});
		return position;
	}

    function getSelectedMarker() {
        // needed in case self.options.selected
        // is changed from outside
        var id = null;
        if (self.resultPlace && self.resultPlace.selected) {
            id = self.resultPlace.id;
        } else {
            Ox.forEach(self.places, function(place) {
                if (place.selected) {
                    id = place.id;
                    return false;
                }
            });
        }
        return id;
    }

    function getSelectedPlace() {
        return self.options.selected ?
            getPlaceById(self.options.selected) : null;
    }

    function initMap() {

        getMapBounds(function(mapBounds) {

            Ox.Log('Map', 'init', mapBounds.getSouthWest(), mapBounds.getNorthEast(), mapBounds.getCenter())

            self.elevationService = new google.maps.ElevationService();
            self.geocoder = new google.maps.Geocoder();
            self.maxZoomService = new google.maps.MaxZoomService();

            self.center = mapBounds ? mapBounds.getCenter() : new google.maps.LatLng(0, 0);
            self.zoom = self.minZoom;
            that.map = self.map = new google.maps.Map(self.$map.$element[0], {
                center: self.center,
                disableDefaultUI: true,
                disableDoubleClickZoom: true,
                mapTypeId: google.maps.MapTypeId[getMapType()],
                zoom: self.zoom
            });
            google.maps.event.addListener(self.map, 'bounds_changed', boundsChanged);
            google.maps.event.addListener(self.map, 'center_changed', centerChanged);
            google.maps.event.addListener(self.map, 'click', clickMap);
            google.maps.event.addListener(self.map, 'idle', mapChanged);
            google.maps.event.addListener(self.map, 'zoom_changed', zoomChanged);
            google.maps.event.addListenerOnce(self.map, 'tilesloaded', tilesLoaded);

            // needed to get mouse x/y coordinates on marker mouseover,
            // see http://code.google.com/p/gmaps-api-issues/issues/detail?id=2342
            that.overlayView = new google.maps.OverlayView();
            that.overlayView.setMap(self.map);
            that.overlayView.draw = function () { 
                if (!this.ready) { 
                    this.ready = true; 
                    google.maps.event.trigger(this, 'ready'); 
                }
            }
            that.overlayView.draw();

            if (self.options.find) {
                self.$findInput.value(self.options.find)
                    .triggerEvent('submit', {value: self.options.find});
            } else if (self.options.selected) {
                selectPlace(self.options.selected, true);
            } else {
                if (mapBounds) {
                    if (isEmpty(mapBounds)) {
                        self.map.setZoom(self.minZoom);
                    } else {
                        self.map.fitBounds(mapBounds);
                    }
                }
                if (self.map.getZoom() < self.minZoom) {
                    self.map.setZoom(self.minZoom);
                }
            }
            updateFormElements();

            // fixme: this is just guessing
            // setTimeout(updateFormElements, 2500);

            self.places = [];
            if (!self.isAsync) {
                self.options.places.forEach(function(place) {
                    self.places.push(new Ox.MapPlace(Ox.extend({
                        map: that
                    }, place)));
                });
            }
            google.maps.event.trigger(self.map, 'resize');
    		//that.gainFocus();
    		that.triggerEvent('load');

        });

		function tilesLoaded() {
		    // fixme: can add earlier, use don't replace map contents option
            Ox.forEach(self.$controls, function($control) {
                $control.appendTo(self.$map);
            });
            Ox.forEach(self.$placeControls, function($placeControl) {
                $placeControl.appendTo(self.$map);
            });
            setTimeout(function() {
                var element = self.$map.$element[0].children[0].children[2].children[0];
                element.style.backgroundImage = '-webkit-linear-gradient(left, rgba(255, 255, 255, 0) 0px, rgba(255, 255, 255, 0.1) 50px)';
                element.style.color = 'rgb(192, 192, 192)';
                element.style.textShadow = '1px 1px 0 rgb(64, 64, 64)';
                element.children[3].style.color = 'rgb(192, 192, 192)';
            }, 0)
		}
    }

    function isEmpty(bounds) {
        // Google's bounds.isEmpty() is not reliable
        var southWest = bounds.getSouthWest(),
            northEast = bounds.getNorthEast();
        return southWest.lat() == northEast.lat()
            && southWest.lng() == northEast.lng();
    }

    function mapChanged() {
        // gets called after panning or zooming
        Ox.Log('Map', 'mapChanged');
        if (self.boundsChanged) {
            var bounds = self.map.getBounds(),
                southWest = bounds.getSouthWest(),
                northEast = bounds.getNorthEast(),
                south = southWest.lat(),
                west = southWest.lng(),
                north = northEast.lat(),
                east = northEast.lng();
            if (!self.isAsync) {
                self.places.sort(function(a, b) {
                    var sort = {
                        a: a.selected ? Infinity
                            : bounds.contains(a.center) ? a.area : -Infinity,
                        b: b.selected ? Infinity
                            : bounds.contains(b.center) ? b.area : -Infinity,
                    };
                    return sort.b - sort.a;
                }).forEach(function(place, i) {
                    if (i < self.options.maxMarkers && !place.visible) {
                        place.add();
                    } else if (i >= self.options.maxMarkers && place.visible) {
                        place.remove();
                    }
                });
            } else {
                self.options.places({
                    keys: self.placeKeys,
                    query: {
                        conditions: Ox.merge([
                            {key: 'lat', value: [south, north], operator: '-'}
                        ], spansGlobe() ? [
                            {key: 'lng', value: [-180, 180], operator: '-'}
                        ] : crossesDateline() ? [
                            {key: 'lng', value: [east, west], operator: '!-'}
                        ] : [
                            {key: 'lng', value: [west, east], operator: '-'}
                        ]),
                        operator: '&'
                    },
                    range: [0, self.options.maxMarkers],
                    sort: [{key: 'area', operator: '-'}]
                }, function(result) {
                    Ox.Log('Map', 'RESULT', result)
                    var ids = [];
                    result.data.items.forEach(function(item, i) {
                        var place = getPlaceById(item.id);
                        if (!place) {
                            self.places.push(
                                new Ox.MapPlace(Ox.extend({
                                    map: that
                                }, item)).add()
                            );
                        } else if (!place.visible) {
                            place.add();
                        }
                        ids.push(item.id);
                    });
                    self.places.forEach(function(place) {
                        if (
                            !place.selected && place.visible
                            && ids.indexOf(place.id) == -1
                        ) {
                            place.remove();
                        }
                    });
                });
            }
            self.boundsChanged = false;
        }
        if (self.centerChanged) {
            getMaxZoom(function(zoom) {
                if (zoom && zoom != self.maxZoom) {
                    self.maxZoom = zoom;
                    if (self.map.getZoom() > zoom) {
                        self.map.setZoom(zoom);
                    }
                    constructZoomInput();
                }
            });
            self.centerChanged = false;
        }
        if (self.zoomChanged) {
            self.zoomChanged = false;
        }
    }

	function pan(x, y) {
		self.map.panBy(x * self.$map.width() / 2, y * self.$map.height() / 2);
	};

    function panToPlace() {
        var place = getSelectedPlace();
		place && self.map.panTo(place.center);
    }

    function parseGeodata(data) {
        var bounds = data.geometry.bounds || data.geometry.viewport,
            northEast = bounds.getNorthEast(),
            southWest = bounds.getSouthWest(),
            place = {
                alternativeNames: [],
                components: data.address_components,
		        countryCode: getCountryCode(data.address_components),
		        east: northEast.lng(),
		        editable: self.options.editable,
		        fullGeoname: getFullGeoname(data.address_components),
		        id: '_' + Ox.encodeBase32(Ox.uid()),
		        map: that,
		        north: northEast.lat(),
		        south: southWest.lat(),
		        type: getType(data.address_components[0].types),
		        west: southWest.lng()
            };
	    place.geoname = data.formatted_address || place.fullGeoname;
        place.name = (place.geoname || place.fullGeoname).split(', ')[0];
        if (Math.abs(place.west) == 180 && Math.abs(place.east) == 180) {
            place.west = -179.99999999;
            place.east = 179.99999999;
        }
        place.south = Ox.limit(place.south, Ox.MIN_LATITUDE, Ox.MAX_LATITUDE - 0.00000001);
        place.north = Ox.limit(place.north, Ox.MIN_LATITUDE + 0.00000001, Ox.MAX_LATITUDE);
        function getCountryCode(components) {
            var countryCode = '';
            Ox.forEach(components, function(component) {
                if (component.types.indexOf('country') > -1) {
                    countryCode = component.short_name;
                    return false;
                }
            });
            return countryCode;
        }
        function getFullGeoname(components) {
            var country = false;
            return components.map(function(component, i) {
                var name = component.long_name;
                if (i && components[i - 1].types.indexOf('country') > -1) {
                    country = true;
                }
                return !country && (
                    i == 0 || name != components[i - 1].long_name
                ) ? name : null;
	        }).join(', ');
        }
        function getType(types) {
            Ox.Log('Map', 'getType', types)
            // see http://code.google.com/apis/maps/documentation/javascript/services.html#GeocodingAddressTypes
            var strings = {
                    'country': ['country'],
                    'region': ['administrative_area', 'colloquial_area'],
                    'city': ['locality'],
                    'borough': ['neighborhood', 'postal_code', 'sublocality'],
                    'street': [
                        'intersection', 'route',
                        'street_address', 'street_number'
                    ],
                    'building': [
                        'airport', 'establishment', 'floor',
                        'premise', 'room', 'subpremise'
                    ]
                },
                type = 'feature';
            Ox.forEach(strings, function(values, key) {
                Ox.forEach(values, function(value) {
                    if (find(value)) {
                        type = key;
                        return false;
                    }
                });
                return type == 'feature';
            });
            return type;
            function find(type) {
                var ret;
                Ox.forEach(types, function(v) {
                    ret = Ox.startsWith(v, type);
                    return !ret;
                });
                return ret;
            }
        }
	    return place;
    }

    function pressEnter() {
        var place = getSelectedPlace();
        if (place) {
            if (place.editing) {
                place.submit();
            } else {
                place.edit();
            }
        } else if (self.resultPlace) {
            selectPlace(self.resultPlace.id)
        }
    }

    function pressEscape() {
        var place = getSelectedPlace();
        if (place) {
            if (place.editing) {
                place.cancel();
            } else {
                selectPlace(null);
            }
        } else if (self.resultPlace) {
            self.resultPlace.remove();
            self.resultPlace = null;
        }
    }

    function removePlace() {
        var place = getSelectedPlace();
        place.id = '_' + place.id;
        self.options.selected = place.id;
        //Ox.Log('Map', 'removePlace', Ox.getObjectById(self.places, place.id))
        self.places.splice(Ox.getPositionById(self.places, place.id), 1);
        self.resultPlace && self.resultPlace.remove();
        self.resultPlace = place;
        place.marker.update();
    }

	function reset() {
		//Ox.Log('Map', self.map.getZoom(), self.zoom);
		self.map.getZoom() == self.zoom ?
			self.map.panTo(self.center) :
			self.map.fitBounds(self.bounds);
	}

    function resizeMap() {
        /*
        Ox.Log('Map', 'resizeMap', self.options.width, self.options.height);
        var center = self.map.getCenter();
        self.mapHeight = getMapHeight();
        self.minZoom = getMinZoom();
        that.css({
            height: self.options.height + 'px',
            width: self.options.width + 'px'
        });
        self.$map.css({
            height: self.mapHeight + 'px',
            width: self.options.width + 'px'
        });
        google.maps.event.trigger(self.map, 'resize');
        self.map.setCenter(center);
        */
    }

    function selectPlace(id, zoom) {
        // id can be null (deselect)
        var place,
            selected = getSelectedMarker();
        Ox.Log('Map', 'Ox.Map selectPlace()', id, selected);
        if (id != selected) {
            place = getPlaceById(selected);
            place && place.deselect();
            if (id !== null) {
                place = getPlaceById(id);
                if (place) {
                    select();
                } else {
                    // async && place doesn't exist yet
                    self.options.places({
                        keys: self.placeKeys,
                        query: {
                            conditions: [
                                {key: 'id', value: id, operator: '=='}
                            ],
                            operator: '&'
                        }
                    }, function(result) {
                        if (result.data.items.length) {
                            place = new Ox.MapPlace(Ox.extend({
                                map: that
                            }, result.data.items[0])).add();
                            self.places.push(place);
                            select();
                            if (zoom) {
                                zoomToPlace();
                            } else {
                                panToPlace();
                            }
                        }
                    });
                }
            } else {
                place = null;
                select();
            }
        }
        function select() {
            place && place.select();
            self.options.selected = id;
            setPlaceControls(place);
            setStatus();
            that.triggerEvent('selectplace', place);
            // FIXME: all these events should rather pass {place: place}
        }
    };

    function setPlaceControls(place) {
        var $placeControls = that.$element.find('.OxPlaceControl'),
            isVisible = self.$placeControls.name.is(':visible');
        if (place) {
            self.$placeControls.flag.empty().append(
                $('<img>').attr({
                    src: Ox.getFlagByGeoname(place.geoname, 16)
                })
            ).show();
            self.$placeControls.name.options({title: place.name})
            !isVisible && $placeControls.show().animate({opacity: 1}, 250);
        } else {
            isVisible && $placeControls.animate({opacity: 0}, 250, function() {
                $placeControls.hide();
            });
        }
    }

    function setScale() {
        var metersPerPixel = getMetersPerPixel();
        Ox.forEach(self.scaleMeters, function(meters) {
            var scaleWidth = Math.round(meters / metersPerPixel);
            if (scaleWidth <= self.options.width / 2 - 4) {
                self.$controls.scale
                    .options({
                        title: '\u2190 ' + (
                            meters > 1000 ? Ox.formatNumber(meters / 1000) + ' k' : meters + ' '
                        ) + 'm \u2192'
                    })
                    .css({
                        width: (scaleWidth - 16) + 'px'
                    })
                return false;
            }
        });
    }

    function setStatus() {
        //Ox.Log('Map', 'setStatus()', self.options.selected)
        var code, country, disabled, place, title;
        if (self.options.statusbar) {
            place = getSelectedPlace();
            country = place ? Ox.getCountryByGeoname(place.geoname) : '';
            code = country ? country.code : 'NTHH';
            disabled = place && !place.editable;
            if (place) {
                title = place.id[0] == '_' ? 'Add Place' : 'Remove Place';
            } else {
                title = 'New Place';
            }
            self.$placeFlag.attr({
                src: Ox.PATH + 'Ox.Geo/png/icons/16/' + code + '.png'
            });
            self.$placeNameInput.options({
                disabled: disabled,
                value: place ? place.name : ''
            });
            self.$placeGeonameInput.options({
                disabled: disabled,
                value: place ? place.geoname : ''
            });
            self.$placeButton.options({
                disabled: disabled,
                title: title
            });
        }
        //Ox.Log('Map', 'STATUS DONE');
    }

    function spansGlobe() {
        // fixme: or self.options.width ??
        return self.$map.width() > Ox.MAP_TILE_SIZE * Math.pow(2, self.map.getZoom());
    };

    function submitFind(data) {
        self.options.find = data.value;
        if (data.value === '') {
            if (self.options.selected[0] == '_') {
                selectPlace(null);
            }
        } else {
            that.findPlace(data.value, function(place) {
                setStatus(place);
            });
        }
    }

    function toggleControls() {
        // fixme: that.find() doesn't work here
        var $controls = that.$element.find('.OxMapControl');
        self.options.showControls = !self.options.showControls;
        if (self.options.showControls) {
            $controls.show().animate({opacity: 1}, 250);
        } else {
            $controls.animate({opacity: 0}, 250, function() {
                $controls.hide();
            });
        }
        that.triggerEvent('togglecontrols', {
            visible: self.options.showControls
        });
    }

    function toggleLabels() {
        self.options.showLabels = !self.options.showLabels;
        self.map.setMapTypeId(google.maps.MapTypeId[getMapType()]);
        that.triggerEvent('togglelabels', {
            visible: self.options.showLabels
        });
    }

    function triggerGeocodeEvent(data) {
        // someone may want to cache google geocode data, so we fire an event.
        // google puts functions like lat or lng on the objects' prototypes,
        // so we create properly named properties, for json encoding
        if (data.latLng) {
            data.latLng = {
                lat: data.latLng.lat(),
                lng: data.latLng.lng()
            }
        }
        data.results.forEach(function(result) {
            ['bounds', 'viewport'].forEach(function(key) {
                if (result.geometry[key]) {
                    result.geometry[key] = {
                        northEast: {
                            lat: result.geometry[key].getNorthEast().lat(),
                            lng: result.geometry[key].getNorthEast().lng()
                        },
                        southWest: {
                            lat: result.geometry[key].getSouthWest().lat(),
                            lng: result.geometry[key].getSouthWest().lng()
                        }
                    }
                }
            });
            if (result.geometry.location) {
                result.geometry.location = {
                    lat: result.geometry.location.lat(),
                    lng: result.geometry.location.lng()
                }
            }
        });
        that.triggerEvent('geocode', data);
    }

    function undo() {
        Ox.Log('Map', 'Map undo')
        var place = getSelectedPlace();
        place.editing && place.undo();
    }

    function updateFormElements() {
        var width = that.width();
        if (self.options.zoombar) {
            getMaxZoom(function(zoom) {
                self.maxZoom = zoom;
                constructZoomInput();
            });
        }
        if (self.options.statusbar) {
            self.$placeNameInput.options({
                width: Math.floor((width - 132) / 2)
            });
            self.$placeGeonameInput.options({
                width: Math.ceil((width - 132) / 2)
            });
        }
    }

	function zoom(z) {
		self.map.setZoom(self.map.getZoom() + z);
	}

    function zoomChanged() {
        var zoom = self.map.getZoom();
        if (zoom < self.minZoom) {
            self.map.setZoom(self.minZoom);
        } else if (self.maxZoom && zoom > self.maxZoom) {
            self.map.setZoom(self.maxZoom);
        } else {
            self.zoomChanged = true;
            self.$zoomInput && self.$zoomInput.value(zoom);
            that.triggerEvent('zoom', {
                value: zoom
            });
        }
    }

	function zoomToPlace() {
	    var place = getSelectedPlace();
		place && self.map.fitBounds(place.bounds);
	}

    self.setOption = function(key, value) {
        if (key == 'height' || key == 'width') {
            that.resizeMap();
        } else if (key == 'places') {
            //fixme: should zoom to new bounds
            zoom(0);
        } else if (key == 'selected') {
            selectPlace(value);
        } else if (key == 'type') {
            
        }
    };

    that.addPlace = function(data) {
        addPlaceToPlaces(data);
    };

    that.getKey = function() {
        var key = null;
        if (self.shiftKey) {
            key = 'shift'
        } else if (self.metaKey) {
            key = 'meta'
        }
        return key;
    };

    that.getSelectedPlace = function() {
        return getSelectedPlace();
    }

    that.editPlace = function() {
        getSelectedPlace().edit();
		return that;
    };

    /*
    that.editPlace = function(data) {
        var place = getPlaceById(self.options.selected);
        place.$marker.options(data);
        return that;
    };
    */

    that.findPlace = function(name, callback) {
        getPlaceByName(name, function(place) {
            if (place) {
                addPlaceToMap(place);
                self.map.fitBounds(place.bounds);
            } else {
                name && self.$findInput.addClass('OxError');
            }
            callback(place);
        });
    };

    that.newPlace = function(place) {
        addPlaceToMap(place);
    };

	that.panToPlace = function() {
	    panToPlace();
		return that;
	};

    that.removePlace = function() {
        // fixme: removePlaceFromPlaces() ?
        removePlace();
		return that;
    };

    that.resizeMap = function() {

        /*
        Ox.Log('Map', 'resizeMap', self.options.width, self.options.height);
        var center = self.map.getCenter();
        self.mapHeight = getMapHeight();
        self.minZoom = getMinZoom();
        that.css({
            height: self.options.height + 'px',
            width: self.options.width + 'px'
        });
        self.$map.css({
            height: self.mapHeight + 'px',
            width: self.options.width + 'px'
        });
        google.maps.event.trigger(self.map, 'resize');
        self.map.setCenter(center);
        */

        /*
        Ox.Log('Map', 'Ox.Map.resizeMap()');
        var center = self.map.getCenter();
        self.options.height = that.$element.height();
        self.options.width = that.$element.width();
        Ox.Log('Map', self.options.width, self.options.height)
        self.$map.css({
            height: self.mapHeight + 'px',
            width: self.options.width + 'px'
        });
        google.maps.event.trigger(self.map, 'resize');
        self.map.setCenter(center);
        self.options.zoombar && self.$zoomInput.options({
            size: self.options.width
        });
        */

        self.options.height = that.$element.height();
        self.options.width = that.$element.width();
        self.mapHeight = getMapHeight();
        self.minZoom = getMinZoom();
        if (self.minZoom > self.map.getZoom()) {
            self.map.setZoom(self.minZoom);
        }
        self.$map.css({
            height: self.mapHeight + 'px',
            width: self.options.width + 'px'
        });
        self.options.zoombar && self.$zoomInput.options({
            size: self.options.width
        });
        updateFormElements();
        google.maps.event.trigger(self.map, 'resize');
		return that;
    }

    that.value = function(id, key, value) {
        // fixme: should be like the corresponding List/TextList/etc value function
        Ox.Log('Map', 'Map.value', id, key, value)
        getPlaceById(id).options(key, value);
    }

    that.zoomToPlace = function() {
        zoomToPlace();
		return that;
    };

    that.zoom = function(value) {
        zoom(value);
		return that;
    };

    return that;

};
