function ListingMap() {
    var me = this;
    this.map = null;
    this.tooltip = null;
    this.allowedBounds = new GLatLngBounds(new GLatLng(38.6, -85.6), new GLatLng(40.2, -83.2));
    this.targetMarker = null;
    this.listingMarkersHash = new Hash();
    this.officeMarkersHash = new Hash();
    this.publicSchoolSearcher = null;
    this.privateSchoolSearcher = null;

    // init function (basically a constructor)
    this.init = function(mapDiv, width, height, zoom, centerLat, centerLng) {
        // map
        this.map = new GMap2(document.getElementById(mapDiv), { size: new GSize(width, height) });
        this.map.setCenter(new GLatLng(centerLat, centerLng), zoom);
        //this.map.setUIToDefault();
        //this.map.disableScrollWheelZoom();
        //this.map.enableContinuousZoom();
        var uiOptions = this.map.getDefaultUI();
        uiOptions.zoom.scrollwheel = false;
        this.map.setUI(uiOptions);
        this.map.enableContinuousZoom();

        // tooltip
        this.tooltip = document.createElement("div");
        this.map.getPane(G_MAP_FLOAT_PANE).appendChild(this.tooltip);
        this.tooltip.style.visibility = "hidden";

        // minimum resolution
        this.setMinResolution(10);

        // map mileage
        $("#mapMileage").text(this.updateMapMileage());

        // local searchers
        this.publicSchoolSearcher = new LocalSearcher("public school", "#8A2bE2", "#000000");
        this.publicSchoolSearcher.init();
        this.privateSchoolSearcher = new LocalSearcher("private school", "#009900", "#000000");
        this.privateSchoolSearcher.init();

        // office markers
        this.mapOfficeMarkers();

        // register events
        // add a move listener to restrict the bounds range
        GEvent.addListener(this.map, "move", function() {
            me.checkBounds();
        });
        // add a moveend listener to retrieve additional properties
        GEvent.addListener(this.map, 'moveend', function() {
            $("#mapMileage").text(me.updateMapMileage());
            me.updateListings(true);
            updateLocalSearch();
        });
    };

    // set min resolution when zooming in/out
    this.setMinResolution = function(res) {
        var mt = this.map.getMapTypes();
        for (var i = 0; i < mt.length; i++) {
            mt[i].getMinimumResolution = function() { return res; }
        }
    };

    // set max resolution when zooming in/out
    this.setMaxResolution = function(res) {
        var mt = this.map.getMapTypes();
        for (var i = 0; i < mt.length; i++) {
            mt[i].getMaximumResolution = function() { return res; }
        }
    };

    // builds updated mileage string
    this.updateMapMileage = function() {
        var sw = this.map.getBounds().getSouthWest();
        var ne = this.map.getBounds().getNorthEast();
        var nw = new GLatLng(ne.lat(), sw.lng());
        var ewMiles = ne.distanceFrom(nw, 3959).toFixed(1);
        var nsMiles = sw.distanceFrom(nw, 3959).toFixed(1);
        return "Currently viewing " + ewMiles + " mi by " + nsMiles + " mi";
    };

    // constrain panning
    this.checkBounds = function() {
        if (me.allowedBounds.contains(me.map.getCenter())) {
            return;
        }
        // outside bounds - move to nearest point
        var C = me.map.getCenter();
        var X = C.lng();
        var Y = C.lat();
        var AmaxX = me.allowedBounds.getNorthEast().lng();
        var AmaxY = me.allowedBounds.getNorthEast().lat();
        var AminX = me.allowedBounds.getSouthWest().lng();
        var AminY = me.allowedBounds.getSouthWest().lat();
        if (X < AminX) { X = AminX; }
        if (X > AmaxX) { X = AmaxX; }
        if (Y < AminY) { Y = AminY; }
        if (Y > AmaxY) { Y = AmaxY; }
        me.map.setCenter(new GLatLng(Y, X));
    };

    // check for map div size changes
    this.checkResize = function() {
        this.map.checkResize();
        $("#mapMileage").text(this.updateMapMileage());
        this.updateListings(true);
        updateLocalSearch();
    };

    // shows map tooltip
    this.showTooltip = function(marker) {
        this.tooltip.innerHTML = marker.tooltip;
        var point = this.map.getCurrentMapType().getProjection().fromLatLngToPixel(this.map.fromDivPixelToLatLng(new GPoint(0, 0), true), this.map.getZoom());
        var offset = this.map.getCurrentMapType().getProjection().fromLatLngToPixel(marker.getPoint(), this.map.getZoom());
        var anchor = marker.getIcon().iconAnchor;
        var width = marker.getIcon().iconSize.width;
        var height = this.tooltip.clientHeight;
        var pos = new GControlPosition(G_ANCHOR_TOP_LEFT, new GSize(offset.x - point.x - anchor.x + width, offset.y - point.y - anchor.y - height));
        pos.apply(this.tooltip);
        this.tooltip.style.visibility = "visible";
    }

    // add marker for target listing
    this.addTargetMarker = function(targetListing) {
        this.targetMarker = new ListingMarker(targetListing.lat, targetListing.lng);
        this.targetMarker.getStreetViewData();
        this.targetMarker.targetListing = targetListing;
        this.targetMarker.gmarker = new GMarker(new GLatLng(targetListing.lat, targetListing.lng));

        // add events
        GEvent.addListener(this.targetMarker.gmarker, "mouseover", function() {
            me.targetMarker.gmarker.tooltip = me.targetMarker.getTargetMouseOverHtml();
            me.showTooltip(me.targetMarker.gmarker);
        });
        GEvent.addListener(this.targetMarker.gmarker, "mouseout", function() {
            me.tooltip.style.visibility = "hidden"
        });
        GEvent.addListener(this.targetMarker.gmarker, 'click', function() {
            var infoHtml = me.targetMarker.getTargetInfoWindowHtml();
            me.map.openInfoWindowHtml(me.targetMarker.gmarker.getLatLng(), infoHtml);
        });

        // add overlay to map
        this.map.addOverlay(this.targetMarker.gmarker);
    }   

    // update listing markers
    this.updateListings = function(setTimer) {
        if ($('#propertiesSearch').attr("checked") || $('#propertiesForSale').attr("checked")) {
            $("#timer").stopTime("getProperties");
            var time = (setTimer) ? 400 : 0;
            $("#timer").oneTime(time, "getProperties", function() {
                // add properties
                var sw = me.map.getBounds().getSouthWest();
                var ne = me.map.getBounds().getNorthEast();
				if($('#propertiesSearch').attr("checked")) { 
					me.addListings(sw.lat(), sw.lng(), ne.lat(), ne.lng(), $("#priceSlider").slider("values", 0), $("#priceSlider").slider("values", 1), $("#minBedsSlider").slider("value"), $("#minBathsSlider").slider("value"), null);
				}
				if($('#propertiesForSale').attr("checked")){
					me.addListings(sw.lat(), sw.lng(), ne.lat(), ne.lng(), null, null, null, null, null);
				}
			});
        }
    }

    // add listings
    this.addListings = function(southWestLat, southWestLng, northEastLat, northEastLng, minPrice, maxPrice, minBeds, minBaths, openHouse) {
        // build query string
        var queryString = "southWestLat=" + southWestLat;
        queryString += "&southWestLong=" + southWestLng;
        queryString += "&northEastLat=" + northEastLat;
        queryString += "&northEastLong=" + northEastLng;
        if (minPrice != null) {
            queryString += "&minPrice=" + minPrice;
        }
        if (maxPrice != null) {
            queryString += "&maxPrice=" + maxPrice;
        }
        if (minBeds != null) {
            queryString += "&minBeds=" + minBeds;
        }
        if (minBaths != null) {
            queryString += "&minBaths=" + minBaths;
        }
        if (openHouse != null) {
            queryString += "&openHouse=" + openHouse;
        }

        $.manageAjax.add('properties', {
            success: function(countHtml) {
                // check count
                //var jsonCount = JSON.parse(countHtml);
                var jsonCount = json_parse(countHtml);
                var count = jsonCount[0].TotalRows;
                var openHouseCount = jsonCount[0].totalOpenHouses;
                if (count > 120) {
                    // too many markers - remove all
                    me.removeListingMarkers();
                    $("#listingCounts").find('label').addClass("mapSliderLabelEmphasis");
                    $("#recordCount").html(count.toString() + " similar properties");
                    if (openHouseCount > 0) {
                        var openHouseTxt = (openHouseCount == 1) ? openHouseCount.toString() + " open house" : openHouseCount.toString() + " open houses";
                        $("#openHouseCount").html(openHouseTxt);
                        $("#openHouseLegend").show();
                    }
                    else {
                        $("#openHouseCount").html("");
                        $("#openHouseLegend").hide();
                    }
                    $("#listingMessage").html("<span style='font-size:9px; color:#0066cc; font-weight:bold; background-color:lightyellow;'>(Search results cannot exceed 120)</span>");
                }
                else {
                    $.manageAjax.add('properties', {
                        success: function(listingsHtml) {
                            var jsonListings = json_parse(listingsHtml);
                            var propertyCount = me.addListingMarkers(jsonListings);
                            var countTxt = (propertyCount == 1) ? propertyCount.toString() + " similar property" : propertyCount.toString() + " similar properties";
                            $("#listingCounts").find('label').removeClass("mapSliderLabelEmphasis");
                            $("#recordCount").html(countTxt);
                            if (openHouseCount > 0) {
                                var openHouseTxt = (openHouseCount == 1) ? openHouseCount.toString() + " open house" : openHouseCount.toString() + " open houses";
                                $("#openHouseCount").html(openHouseTxt);
                                $("#openHouseLegend").show();
                            }
                            else {
                                $("#openHouseCount").html("");
                                $("#openHouseLegend").hide();
                            }
                            $("#listingMessage").html("");
                        },
                        url: "includes/map/GetPropertiesForSale.asp?" + queryString
                    });
                }
            },
            url: "includes/map/GetPropertiesForSaleCount.asp?" + queryString
        });
    }    

    // add listing markers
    this.addListingMarkers = function(markerArr) {
        var tmplistingMarkersHash = new Hash();
        var len = markerArr.length;
        var listingCount = 0;
        this.targetMarker.listings = new Hash();
        for (var i = 0; i < len; i++) {
            var listing = Listing.resultToListing(markerArr[i]);

            // ensure we have geolocation
            if (listing.lat == 0 && listing.lng == 0) {
                continue;
            }

            // generate hash keys
            var markerHashKey = listing.lat.toString() + "|" + listing.lng.toString();
            var listingHashKey = listing.mlsBoardId + "|" + listing.mlsNumber;

            // verify listing is featured marker/listing
            if (listing.lat == this.targetMarker.lat && listing.lng == this.targetMarker.lng) {
                if (listingHashKey == this.targetMarker.targetListing.mlsBoardId + "|" + this.targetMarker.targetListing.mlsNumber) {
                    // target listing - skip it
                    continue;
                }
                // same geolocation as target listing
                this.targetMarker.listings.setItem(listingHashKey, listing);
                listingCount++;
                continue;
            }

            // ensure there is not a marker for this location
            if (tmplistingMarkersHash.hasItem(markerHashKey)) {
                // marker exists
                var listingMarker = tmplistingMarkersHash.getItem(markerHashKey);
                // ensure listing is not already included on this marker
                if (listingMarker.listings.hasItem(listingHashKey)) {
                    // marker already contains listing
                    continue;
                }
                else {
                    // marker does not contain listing - add listing
                    if (listingMarker.openHouse || listing.openHouse) {
                        listingMarker.openHouse = true;
                    }
                    listingMarker.listings.setItem(listingHashKey, listing);
                }
                // add marker to hash
                tmplistingMarkersHash.setItem(markerHashKey, listingMarker);
            }
            else {
                // marker does not exist - add new marker
                var listingMarker = new ListingMarker(listing.lat, listing.lng);
                listingMarker.getStreetViewData();
                listingMarker.listings.setItem(listingHashKey, listing);
                listingMarker.openHouse = listing.openHouse;
                tmplistingMarkersHash.setItem(markerHashKey, listingMarker);
            }
            listingCount++;
        }

        // remove markers that aren't within map bounds or meet search criteria
        for (var i in this.listingMarkersHash.items) {
            if (tmplistingMarkersHash.hasItem(i)) {
                // leave this marker
                continue;
            }
            else {
                // remove marker
                this.map.removeOverlay(this.listingMarkersHash.items[i].gmarker);
                this.listingMarkersHash.removeItem(i);
            }
        }

        // iterate over markers and add overlays
        var propertyIcon = this.createCustomIcon("#0000FF", "#000000");
        var openHouseIcon = this.createCustomIcon("#FFD700", "#660000");
        var mapBounds = this.map.getBounds();
        for (var i in tmplistingMarkersHash.items) {
            if (this.listingMarkersHash.hasItem(i)) {
                // map already has marker for this geolocation
                continue;
            }
            else {
                // overlay does not exist yet - add it
                var listingMarker = tmplistingMarkersHash.getItem(i);
                var markerIcon;
                if (listingMarker.openHouse == 1) {
                    markerIcon = openHouseIcon;
                }
                else {
                    markerIcon = propertyIcon;
                }
                var marker = this.createListingMarker(listingMarker, markerIcon);
                listingMarker.gmarker = marker;
                this.listingMarkersHash.setItem(i, listingMarker);
                this.map.addOverlay(marker);
            }
        }
        tmplistingMarkersHash = [];
        return listingCount;
    }

    // creates a custom teardrop icon w/ specified primary and stroke colors
    this.createCustomIcon = function(primaryColor, strokeColor) {
        var iconOptions = {};
        iconOptions.width = 24;
        iconOptions.height = 24;
        iconOptions.primaryColor = primaryColor;
        iconOptions.cornerColor = "#FFFFFF";
        iconOptions.strokeColor = strokeColor;
        var icon = MapIconMaker.createMarkerIcon(iconOptions);
        return icon;
    }    

    // create listing marker
    this.createListingMarker = function(listingMarker, markerIcon) {
        if (listingMarker.listings.length > 0 && listingMarker.lat != 0 && listingMarker.lng != 0) {
            var point = new GLatLng(listingMarker.lat, listingMarker.lng);
            var marker = new GMarker(point, { icon: markerIcon });

            GEvent.addListener(marker, 'click', function() {
                var infoHtml = listingMarker.getInfoWindowHtml();
                me.map.openInfoWindowHtml(point, infoHtml);
            });
            GEvent.addListener(marker, "mouseover", function() {
                marker.tooltip = listingMarker.getMouseOverHtml();
                me.showTooltip(marker);
            });
            GEvent.addListener(marker, "mouseout", function() {
                me.tooltip.style.visibility = "hidden"
            });
            return marker;
        }
    }

    // remove listing markers
    this.removeListingMarkers = function() {
        for (var i in this.listingMarkersHash.items) {
            this.map.removeOverlay(this.listingMarkersHash.items[i].gmarker);
        }
        this.listingMarkersHash.clear();
    }

    // add sibcy cline office markers
    this.mapOfficeMarkers = function() {
        $.ajax({
            type: "GET",
            url: "includes/map/SibcyClineOffices.xml",
            dataType: "xml",
            success: function(xml) {
                $(xml).find('office').each(function() {
                    // create office object
                    var office = new Office(
                        $(this).attr('id'),
                        $(this).find('name').text(),
                        $(this).find('streetAddress').text(),
                        $(this).find('state').text(),
                        $(this).find('zip').text(),
                        $(this).find('phone').text(),
                        $(this).find('lat').text(),
                        $(this).find('lng').text(),
                        $(this).find('imgUrl').text());

                    // init office marker
                    var officeMarker = new OfficeMarker();
                    officeMarker.lat = office.lat;
                    officeMarker.lng = office.lng;
                    officeMarker.office = office;
                    
                    // create marker and events
                    var marker = me.createOfficeMarker(officeMarker)
                    officeMarker.gmarker = marker;
                    
                    // add to marker hash and map
                    var key = office.lat + "|" + office.lng;
                    me.officeMarkersHash.setItem(key, officeMarker);
                    me.map.addOverlay(marker);
                });
            }
        });
    }; 
    
    // create office marker
    this.createOfficeMarker = function(officeMarker) {
        var point = new GLatLng(officeMarker.lat, officeMarker.lng);
        var markerIcon = officeMarker.getMarkerIcon();
        var marker = new GMarker(point, { icon: markerIcon });
        marker.bindInfoWindow(officeMarker.getInfoWindowHtml());
        //var marker = new GMarker(point);

//        GEvent.addListener(marker, 'click', function() {
//            var infoHtml = officeMarker.getInfoWindowHtml();
//            me.map.openInfoWindowHtml(point);
//        });
        GEvent.addListener(marker, "mouseover", function() {
            marker.tooltip = officeMarker.getMouseOverHtml();
            me.showTooltip(marker);
        });
        GEvent.addListener(marker, "mouseout", function() {
            me.tooltip.style.visibility = "hidden"
        });
        return marker;
    };

    // reset map on center point
    this.recenterMap = function() {
        this.map.panTo(new GLatLng(this.targetMarker.lat, this.targetMarker.lng));
    };
    
}
