<?PHP
#
#   FILE:  GoogleMaps.php
#
#   Part of the Collection Workflow Integration System (CWIS)
#   Copyright 2002-2013 Edward Almasy and Internet Scout Research Group
#   http://scout.wisc.edu/cwis/
#

/**
* Adds the ability to insert a Google Maps window into a CWIS interface.
*/
class GoogleMaps extends Plugin
{

    /**
    * Register information about this plugin.
    */
    function Register()
    {
        $this->Name = "Google Maps";
        $this->Version = "1.2.6";
        $this->Description = "This plugin adds the ability to insert a Google"
                ." Maps window into a CWIS interface.";
        $this->Author = "Internet Scout";
        $this->Url = "http://scout.wisc.edu/cwis/";
        $this->Email = "scout@scout.wisc.edu";
        $this->Requires = array(
            "CWISCore" => "2.1.2"
            );
        $this->CfgSetup["ExpTime"] = array(
            "Type" => "Number",
            "MaxVal" => 31556926,
            "Label" => "Geocode Cache Expiration Time",
            "Help" => "Specifies how long to cache geocode results, in seconds.");
        $this->CfgSetup["DefaultPointField"] = array(
            "Type" => "MetadataField",
            "FieldTypes" =>
            MetadataSchema::MDFTYPE_PARAGRAPH | MetadataSchema::MDFTYPE_TEXT | MetadataSchema::MDFTYPE_POINT,
            "Label" => "Default field for map locations",
            "Help" =>
            "If your geodata is Latitude/Longitude in a point field or addresses in a text field, ".
            "select that field here.  You may then signal ".
            "GoogleMaps_EVENT_HTML_TAGS_SIMPLE in your HTML and won't need to write any PHP."
            );
        $this->CfgSetup["DefaultSqlQuery"] = array(
            "Type" => "Paragraph",
            "Label" => "SQL to generate map locations",
            "Help" =>
            "An SQL select statement which returns your geodata in columns named: ".
            "Latitude, Longitude, ResourceId, MarkerColor, MarkerLabel, LabelColor. ".
            "Column order is not significant, but column names are. ".
            "Latitude, Longitude, and ResourceId are required, the rest are optional. ".
            "NOTE: if you specify both an SQL query and a metadata field, the metadata ".
            "field will be used."
            );
    }

    /**
    * Initialize default settings.
    */
    function Install()
    {
        $this->Upgrade("X-NO-VERSION-X");
    }

    /**
    * Uninstall the plugin.
    * @return Returns  NULL if successful or an error message otherwise.
    */
    public function Uninstall()
    {
        $Database = new Database();

        # callbacks table
        if (FALSE === $Database->Query("DROP TABLE GoogleMaps_Callbacks;"))
        { return "Could not remove the callbacks table"; }

        # geocodes table
        if (FALSE === $Database->Query("DROP TABLE GoogleMaps_Geocodes;"))
        { return "Could not remove the geocodes table"; }

        # remove the cache directory
        if (!RemoveFromFilesystem($this->GetCachePath()))
        {
            return "Could not delete the cache directory.";
        }
    }

    /**
    * Upgrade from a previous version.
    * @param $PreviousVersion Previous version number as a string.
    */
    function Upgrade($PreviousVersion)
    {
        $DB = new Database();

        switch ($PreviousVersion)
        {
        case "X-NO-VERSION-X":
        case "1.0.1":
            $DB->Query("CREATE TABLE GoogleMapsCallbacks ("
                       ."Id VARCHAR(32) UNIQUE, "
                       ."Payload TEXT,"
                       ."LastUsed TIMESTAMP,"
                       ." INDEX (Id))");
        case "1.0.2":
            $DB->Query("CREATE TABLE GoogleMapsGeocodes ("
                       ."Id VARCHAR(32) UNIQUE, "
                       ."Lat DOUBLE, "
                       ."Lng DOUBLE, "
                       ."LastUpdate TIMESTAMP, "
                       ."INDEX (Id))");
            $this->ConfigSetting("ExpTime", 604800);
        case "1.0.3":
            $this->ConfigSetting("DefaultGridSize",10);
            $this->ConfigSetting("DesiredPointCount",100);
            $this->ConfigSetting("MaxIterationCount",10);
        case "1.0.4":
            $DB->Query("ALTER TABLE GoogleMapsCallbacks "
                       ."ADD COLUMN Params TEXT");
        case "1.1.0":
            $this->CheckCacheDirectory();
        case "1.2.1":
        case "1.2.2":
            $DB->Query("ALTER TABLE GoogleMapsCallbacks "
                       ."RENAME TO GoogleMaps_Callbacks");
            $DB->Query("ALTER TABLE GoogleMapsGeocodes "
                       ."RENAME TO GoogleMaps_Geocodes");
        default:
        }
    }

    /**
    * Declare the events this plugin provides to the application framework.
    * @return Returns an array of the events this plugin provides to the
    *      application framework.
    */
    function DeclareEvents()
    {
        return array(
            "GoogleMaps_EVENT_HTML_TAGS"
            => ApplicationFramework::EVENTTYPE_DEFAULT,
            "GoogleMaps_EVENT_HTML_TAGS_SIMPLE"
            => ApplicationFramework::EVENTTYPE_DEFAULT,
            "GoogleMaps_EVENT_STATIC_MAP"
            => ApplicationFramework::EVENTTYPE_DEFAULT,
            "GoogleMaps_EVENT_CHANGE_POINT_PROVIDER"
            => ApplicationFramework::EVENTTYPE_DEFAULT,
            "GoogleMaps_EVENT_GEOCODE"
            => ApplicationFramework::EVENTTYPE_FIRST,
            "GoogleMaps_EVENT_DISTANCE"
            => ApplicationFramework::EVENTTYPE_FIRST,
            "GoogleMaps_EVENT_BEARING"
            => ApplicationFramework::EVENTTYPE_FIRST,
            "GoogleMaps_EVENT_GET_KML"
            => ApplicationFramework::EVENTTYPE_FIRST);
    }

    /**
    * Hook the events into the application framework.
    * @return Returns an array of events to be hooked into the application
    *      framework.
    */
    function HookEvents()
    {
        return array(
            "EVENT_DAILY" => "CleanCaches",
            "GoogleMaps_EVENT_HTML_TAGS" => "GenerateHtmlTags",
            "GoogleMaps_EVENT_HTML_TAGS_SIMPLE" => "GenerateHtmlTagsSimple",
            "GoogleMaps_EVENT_STATIC_MAP"   => "StaticMap",
            "GoogleMaps_EVENT_CHANGE_POINT_PROVIDER" => "ChangePointProvider",
            "GoogleMaps_EVENT_GEOCODE" => "Geocode",
            "GoogleMaps_EVENT_DISTANCE" => "ComputeDistance",
            "GoogleMaps_EVENT_BEARING" => "ComputeBearing",
            "GoogleMaps_EVENT_GET_KML" => "GetKml");
    }

    /**
    * Generates the HTML tags to make the Google Maps widget.
    *
    * Takes two parameters, a PointProvider and a DetailProvider.
    * Both are the PHP callbacks, the former takes a user-provided
    * array, and is expected to return all of the points with GPS
    * coordinates. The latter should take an id number (usually a
    * ResourceId) and a user-provided array and print a fragment of
    * HTML to display in the info window that pops up over the map
    * marker for that resource.  Anything that can be a php callback
    * is fair game.
    *
    * If you're using functions, they need to be part of the
    * environment when the helper pages for the plugin are loaded.
    * To accomplish this, put them in files called
    * F-(FUNCTION_NAME).html or F-(FUNCTION_NAME).php in your
    * 'interface', 'local/pages' or inside your interface directory.
    *
    * If you're using object methods, the objects will need to be
    * somewhere that the ApplicationFramework's object loading will
    * look, 'local/objects' is likely best.
    *
    * @param callback $PointProvider Callback that provides point information.
    * @param array $PointProviderParams Parameters passed to the point
    *      provider when it's called.
    * @param callback $DefaultProvider Callback that provides detailed
    *      information about a point.
    * @param array $DetailProviderParams Parameters passed to the detail
    *      provider when it's called.
    * @param int $DefaultLat Latitude of initial center of the map.
    * @param int $DefaultLon Longitude of the initial center of the map.
    * @param string $InfoDisplayEvent Event that should trigger showing detailed
    *      point information.
    * @param string $KmlPage Page that generates KML for Google.
    * @see GenerateHTMLTagsSimple()
    */
    function GenerateHTMLTags($PointProvider, $PointProviderParams,
                              $DetailProvider, $DetailProviderParams,
                              $DefaultLat, $DefaultLon, $DefaultZoom,
                              $InfoDisplayEvent, $KmlPage="P_GoogleMaps_GetKML")
    {
        $UseSsl = isset($_SERVER["HTTPS"]);
        $Protocol = $UseSsl ? "https://" : "http://";

        # Spit out the html tags required for the map
        print('<div class="GoogleMap"><br/><br/><br/><br/><center>'
                .'<span style="color: #DDDDDD;">[JavaScript Required]'
                .'</span></center></div>');

        $Includes = array(
            './include/jquery.cookie.js',
            './plugins/GoogleMaps/GoogleMapsExtensions.js');

        if ($UseSsl)
        {
            array_unshift(
                $Includes,
                'https://maps-api-ssl.google.com/maps/api/js?sensor=false');
        }

        else
        {
            array_unshift(
                $Includes,
                'http://maps.google.com/maps/api/js?sensor=false');
        }

        foreach($Includes as $Script)
        {
            print('<script type="text/javascript"'
                  .' src="'.$Script.'"></script>');
        }

        # make sure the parameters are serialized the same, regardless of the
        # order in which they're added
        ksort($PointProviderParams);
        ksort($DetailProviderParams);

        $PPSerial = serialize($PointProvider);
        $PPParamsSerial = serialize($PointProviderParams);
        $PPHash = md5($PPSerial.$PPParamsSerial);

        $DPSerial = serialize($DetailProvider);
        $DPParamsSerial = serialize($DetailProviderParams);
        $DPHash = md5($DPSerial.$DPParamsSerial);

        $DB = new Database();

        $DB->Query(
            "INSERT IGNORE INTO GoogleMaps_Callbacks "
            ."(Id, Payload, Params, LastUsed) VALUES "
            ."('".$PPHash."','".addslashes($PPSerial)."','".addslashes($PPParamsSerial)."',NOW()),"
            ."('".$DPHash."','".addslashes($DPSerial)."','".addslashes($DPParamsSerial)."',NOW())");

        $MyLocation = $Protocol.$_SERVER['SERVER_NAME'].
            ( strpos($_SERVER['REQUEST_URI'], '.php') ?
              dirname($_SERVER['REQUEST_URI']).'/' :
              $_SERVER['REQUEST_URI']);

        $MapApplication = str_replace(
            array("X-POINT-PROVIDER-X", "X-DETAIL-PROVIDER-X",
                  "X-DEFAULT-LAT-X","X-DEFAULT-LON-X","X-DEFAULT-ZOOM-X",
                  "X-DESIRED-POINT-COUNT-X","X-INFO-DISPLAY-EVENT-X",
                  "X-BASE-URL-X", "X-KML-PAGE-X"),
            array($PPHash, $DPHash, $DefaultLat, $DefaultLon, $DefaultZoom,
                  $this->ConfigSetting("DesiredPointCount"),
                  $InfoDisplayEvent, $MyLocation, $KmlPage),
            file_get_contents(
                "./plugins/GoogleMaps/GoogleMapsDisplay.js"));

        print '<style type="text/css">';
        print file_get_contents("plugins/GoogleMaps/GoogleMapsDisplay.css");
        print '</style>';

        print('<script type="text/javascript">'."\n");
        print($MapApplication);
        print('</script>'."\n");
    }

    /**
    * Generates the HTML tags to make the Google Maps widget using the default
    * point and detail provider.
    * @param int $DefaultLat Latitude of initial center of the map.
    * @param int $DefaultLon Longitude of the initial center of the map.
    * @param int $DefaultZoom Zoom level to use initially.
    * @param string $InfoDisplayEvent Event that should trigger showing detailed
    *      point information.
    * @see GenerateHTMLTags()
    */
    function GenerateHTMLTagsSimple($DefaultLat, $DefaultLon, $DefaultZoom, $InfoDisplayEvent)
    {
        $PointProvider = array("GoogleMaps", "DefaultPointProvider");
        $PointProviderParams = array();
        $DetailProvider = array("GoogleMaps", "DefaultDetailProvider");
        $DetailProviderParams = array();

        $this->GenerateHTMLTags($PointProvider, $PointProviderParams,
                                $DetailProvider, $DetailProviderParams,
                                $DefaultLat, $DefaultLon, $DefaultZoom,
                                $InfoDisplayEvent);
    }

    /**
    * Generates JavaScript code that can be used to change a point provider. Can
    * be useful with a jQuery .click() to avoid reloading maps.
    * @param callback $PointProvider Callback used to provide points.
    * @param array $Params Parameters to pass to the point provider callback.
    * @see DefaultPointProvider()
    */
    function ChangePointProvider($PointProvider, $Params)
    {
        # make sure the parameters are serialized the same, regardless of the
        # order in which they're added
        ksort($Params);

        $PPSerial = serialize($PointProvider);
        $PPParamsSerial = serialize($Params);
        $PPHash = md5($PPSerial.$PPParamsSerial);

        $DB = new Database();

        $DB->Query(
            "INSERT IGNORE INTO GoogleMaps_Callbacks "
            ."(Id, Payload, Params, LastUsed) VALUES "
            ."('".$PPHash."','".addslashes($PPSerial)."','".addslashes($PPParamsSerial)."',NOW())");

        print("change_point_provider('".$PPHash."');");
    }

    /**
    * Generates and prints a static map image. This makes use of the Google
    * Static Maps API.
    *
    * Google's docs are here:
    * http://code.google.com/apis/maps/documentation/staticmaps/
    *
    * @param int $Lat Latitude of the center of the map image.
    * @param int $Long Longitude of the center of the map image.
    * @param int $Width Width of the map image.
    * @param int $Height Height of the map image.
    * @param int $Zoom Zoom level of the maps image.
    */
    function StaticMap($Lat, $Long, $Width, $Height, $Zoom=14)
    {
        $UseSsl = isset($_SERVER["HTTPS"]);
        $Host = $UseSsl ? "https://maps.googleapis.com" : "http://maps.google.com";

        $Url= $Host."/maps/api/staticmap?maptype=roadmap"
            ."&size=".$Width."x".$Height
            ."&zoom=".$Zoom
            ."&markers=".$Lat.",".$Long
            ."&sensor=false";

        print('<img src="'.$Url.'" alt="Google Map">');
    }

    /**
    * Given an address, get the latitude and longitude of its coordinates.
    *
    * Details on Google's Geocoding API are here:
    * http://code.google.com/apis/maps/documentation/geocoding/
    *
    * NB: Geocoding is rate and quantity limited (see the "Limits"
    * section in Google's docs). As of this writing, they allow only
    * 2500 requests per day. Geocode requests sent from servers
    * (rather than via Firefox or IE) appear to be answered slowly,
    * taking about one minute per reply.  Furthermore, google
    * explicitly states that they will block access to their service
    * which is "abusive".
    *
    * To avoid potentials with the rate/quantity issue, this geocoder
    * caches results for up to a week.  If an address is requested
    * which is not in the cache, NULL will be returned and the
    * geocoding request will take place in the background.
    *
    * @param string $Address Address of a location.
    * @param bool $Foreground TRUE to wait for an address, rather than
    *   spawning a background task to fetch it.
    */
    function Geocode($Address, $Foreground=FALSE)
    {
        $DB = new Database();

        # Then look for the desired address
        $DB->Query("SELECT Lat,Lng FROM GoogleMaps_Geocodes "
                   ."WHERE Id='".md5($Address)."'");

        # If we couldn't find the desired address:
        if ($DB->NumRowsSelected()==0)
        {
            # If we're running in the foreground, fetch it now,
            #  otherwise, use a background task to fetch it.
            if ($Foreground)
            {
                $this->GeocodeRequest($Address);
                $DB->Query("SELECT Lat,Lng FROM GoogleMaps_Geocodes "
                           ."WHERE Id='".md5($Address)."'");
                $Row = $DB->FetchRow();
            }
            else
            {
                # If we can't find it, set up a Geocoding request and return NULL;
                global $AF;
                $AF->QueueUniqueTask(
                    array($this,'GeocodeRequest'),
                    array($Address),
                    ApplicationFramework::PRIORITY_HIGH
                    );
                return NULL;
            }
        }
        else
        {
            $Row = $DB->FetchRow();
        }

        return ($Row["Lat"] !== DB_NOVALUE) ?
            array("Lat" => $Row["Lat"], "Lng" => $Row["Lng"]) :
            NULL ;
    }

    /**
    * Perform the request necessary to geocode an address.
    * @param string $Address Address of a location.
    */
    function GeocodeRequest($Address)
    {
        $Data = file_get_contents(
            "http://maps.google.com/maps/api/geocode/xml?sensor=false&address="
            .urlencode($Address), false,
            stream_context_create(
                array( 'http' => array(
                           'method' => "GET",
                           'header'=>"User-Agent: GoogleMaps/".$this->Version
                           ." CWIS/".CWIS_VERSION." PHP/".PHP_VERSION."\r\n"))
                )
            );

        $DB = new Database();
        $ParsedData = simplexml_load_string($Data);
        if ($ParsedData->status == "OK")
        {
            $DB->Query(
                "INSERT INTO GoogleMaps_Geocodes "
                ."(Id,Lat,Lng,LastUpdate) VALUES ("
                ."'".md5($Address)."',"
                .floatval($ParsedData->result->geometry->location->lat).","
                .floatval($ParsedData->result->geometry->location->lng).","
                ."NOW()"
                .")");
        }
        else
        {
            $DB->Query(
                "INSERT INTO GoogleMaps_Geocodes "
                ."(Id,Lat,Lng,LastUpdate) VALUES "
                ."('".md5($Address)."',NULL,NULL,NOW())");
        }
    }

    /**
    * Computes the distance in kilometers between two points, assuming a
    * spherical earth.
    * @param int $LatSrc Latitude of the source coordinate.
    * @param int $LonSrc Longitude of the source coordinate.
    * @param int $LatDst Latitude of the destination coordinate.
    * @param int $LonDst Longitude of the destination coordinate.
    * @return Returns the distance in kilometers between the two points.
    */
    function ComputeDistance($LatSrc, $LonSrc,
                             $LatDst, $LonDst)
    {
        # See http://en.wikipedia.org/wiki/Great-circle_distance

        # Convert it all to Radians
        $Ps = deg2rad($LatSrc);
        $Ls = deg2rad($LonSrc);
        $Pf = deg2rad($LatDst);
        $Lf = deg2rad($LonDst);

        # Compute the central angle
        return 6371.01 * atan2(
            sqrt( pow(cos($Pf)*sin($Lf-$Ls),2) +
                  pow(cos($Ps)*sin($Pf) -
                      sin($Ps)*cos($Pf)*cos($Lf-$Ls),2)),
                  sin($Ps)*sin($Pf)+cos($Ps)*cos($Pf)*cos($Lf-$Ls));

    }

    /**
    * Computes the initial angle on a course connecting two points, assuming a
    * spherical earth.
    * @param int $LatSrc Latitude of the source coordinate.
    * @param int $LonSrc Longitude of the source coordinate.
    * @param int $LatDst Latitude of the destination coordinate.
    * @param int $LonDst Longitude of the destination coordinate.
    * @param Returns the initial angle on a course connecting two points.
    */
    function ComputeBearing($LatSrc, $LonSrc,
                            $LatDst, $LonDst)
    {
        # See http://mathforum.org/library/drmath/view/55417.html

        # Convert angles to radians
        $Ps = deg2rad($LatSrc);
        $Ls = deg2rad($LonSrc);
        $Pf = deg2rad($LatDst);
        $Lf = deg2rad($LonDst);

        return rad2deg(atan2(sin($Lf-$Ls)*cos($Pf),
                             cos($Ps)*sin($Pf)-sin($Ps)*cos($Pf)*cos($Lf-$Ls)));
    }

    /**
    * Periodic function to clean old data from DB cache tables.
    */
    function CleanCaches()
    {
        $DB = new Database();

        # Clean the cache of entries older than the configured expiration time
        $DB->Query("DELETE FROM GoogleMaps_Geocodes WHERE "
                   ."NOW() - LastUpdate >".$this->ConfigSetting("ExpTime"));

        $DB->Query("DELETE FROM GoogleMaps_Callbacks WHERE "
                   ."NOW() - LastUsed > 86400");
    }

    /**
    * Get the file path to the KML file named by the given point and detail
    * provider hashes. This will get the cached KML file if it exists or will
    * generate it first if it does not exist.
    * @param string $PointProviderHash Point provider hash.
    * @param string $DetailProviderHash Detail provider hash.
    * @return Returns the path to the cached KML file.
    */
    public function GetKml($PointProviderHash, $DetailProviderHash)
    {
        global $AF;

        $Kml = $this->GetKmlFilePath($PointProviderHash, $DetailProviderHash);

        # the file hasn't been generated yet
        if (!file_exists($Kml))
        {
            $this->GenerateKml($PointProviderHash, $DetailProviderHash);
        }

        # the file has been already generated so don't generate it now, but
        # update it in the background
        else
        {
            $AF->QueueUniqueTask(
                array($this, "GenerateKml"),
                array($PointProviderHash, $DetailProviderHash),
                ApplicationFramework::PRIORITY_BACKGROUND,
                "Update a KML cache file.");
        }

        return $Kml;
    }

    /**
    * Generate the cached KML file named by the given point and detail provider
    * hashes.
    * @param string $PointProviderHash Point provider hash.
    * @param string $DetailProviderHash Detail provider hash.
    * @see GetKmlFilePath()
    */
    public function GenerateKml($PointProviderHash, $DetailProviderHash)
    {
        global $AF;

        # make sure the cache directories exist and are usable before attempting
        # to generate the KML
        if (strlen($this->CheckCacheDirectory()))
        {
            return;
        }

        $DB = new Database();
        $Path = $this->GetKmlFilePath($PointProviderHash, $DetailProviderHash);

        # Pull out the point provider callback, user spec'd parameters, and try
        # to load the callback
        $Row = $DB->Query("
            SELECT Payload,Params FROM GoogleMaps_Callbacks
            WHERE Id='".addslashes($PointProviderHash)."'");
        $Row = $DB->FetchRow();
        $PPCallback = unserialize($Row["Payload"]);
        $PPParams   = unserialize($Row["Params"]);

        # Pull out the detail provider callback, user spec'd parameters, and try
        # to load the callback
        $DB->Query("
            SELECT Payload, Params FROM GoogleMaps_Callbacks
            WHERE Id='".addslashes($DetailProviderHash)."'");
        $Row = $DB->FetchRow();
        $DPCallback = unserialize($Row["Payload"]);
        $DPParams   = unserialize($Row["Params"]);

        # initialize the KML file
        $Kml = '<?xml version="1.0" encoding="UTF-8"?>'."\n";
        $Kml .= '<kml xmlns="http://www.opengis.net/kml/2.2">'."\n";
        $Kml .= "<Document>\n";

        if ($AF->LoadFunction($PPCallback) && $AF->LoadFunction($DPCallback))
        {
            # Call the supplied detail provider, expecting an Array.
            $Points = call_user_func_array($PPCallback, array($PPParams));

            # add log message if no points where found
            if (count($Points) < 1)
            {
                $Message = "[GoogleMaps] No points found for parameters: ";
                $Message .= var_export(array(
                    "Parameters" => $PPParams),
                    TRUE);

                $AF->LogMessage(ApplicationFramework::LOGLVL_INFO, $Message);
            }

            # Enumerate the different marker types that we're using:
            $MarkerTypes = array();
            foreach ($Points as $Point)
            {
                $BgColor = str_replace('#','',$Point[3]);
                $Label   = defaulthtmlentities($Point[4]);
                $FgColor = str_replace('#','',$Point[5]);

                $MarkerTypes[$Label.$BgColor.$FgColor] =
                    "http://chart.apis.google.com/chart"
                    ."?chst=d_map_pin_letter_withshadow"
                    ."&chld=".urlencode($Label."|".$BgColor."|".$FgColor);
            }

            # Style elements to define markers:
            foreach ($MarkerTypes as $Key => $Value)
            {
                $Kml .= '<Style id="_'.defaulthtmlentities($Key).'">';
                $Kml .= '<IconStyle>';
                $Kml .= '<Icon><href>'.defaulthtmlentities($Value).'</href></Icon>';

                # the offset (starts from lower left) for the anchor of the
                # marker image from the Google Chart API is 11 px and 2px, but
                # the markers are 40x37 and get resized by Google Maps to the
                # maximum of 32x32, so the actual offset is 9px and 2px
                $Kml .= '<hotSpot x="9" y="2" xunits="pixels" yunits="pixels" />';

                $Kml .= '</IconStyle>';
                $Kml .= '</Style>';
            }


            # Keep track of how many markers are placed at each point:
            $MarkersAtPoint = array();

            # Point elements:
            foreach ($Points as $Point)
            {
                $Lat = $Point[0];
                $Lon = $Point[1];
                $Id  = $Point[2];
                $BgColor = $Point[3];
                $Label   = $Point[4];
                $FgColor = $Point[5];

                $ix =  "X-".$Lat.$Lon."-X";
                if ( !isset($MarkersAtPoint[$ix]) )
                {
                    $MarkersAtPoint[ $ix ] = 1;
                }
                else
                {

                    $Lat += 0.005 * $MarkersAtPoint[$ix];
                    $Lon += 0.005 * $MarkersAtPoint[$ix];

                    $MarkersAtPoint[ $ix ] ++;
                }

                $Kml .= '<Placemark id="_'.defaulthtmlentities($Id).'">';
                $Kml .= '<name></name>';
                $Kml .= '<description><![CDATA[';

                # add the description text/HTML
                ob_start();
                call_user_func_array(
                    $DPCallback,
                    array("Id" => $Id, "Params" => $DPParams));
                $Kml .= ob_get_contents();
                ob_end_clean();

                $Kml .= ']]></description>';

                $Kml .= '<styleUrl>#_'.$Label.$BgColor.$FgColor.'</styleUrl>';
                $Kml .= '<Point><coordinates>'.$Lat.','.$Lon.'</coordinates></Point>';
                $Kml .= '</Placemark>';
            }
        }

        else
        {
            $Message = "[GoogleMaps] Could not load callbacks: ";
            $Message .= var_export(array(
                "Point Provider Callback" => $PPCallback,
                "Detail Provider Callback" => $DPCallback),
                TRUE);

            $AF->LogMessage(ApplicationFramework::LOGLVL_ERROR, $Message);
        }

        # complete the KML document
        $Kml .= "</Document></kml>";
        file_put_contents($Path, $Kml);
    }

    /**
    * Provides a default point provider that retrieves all points from the
    * default point field.
    * @param array $Params Parameters to the point provider. The default point
    *      provider doesn't use them.
    * @return Returns an array of points and associated data.
    */
    public static function DefaultPointProvider($Params)
    {
        global $DB;

        $rc = array();

        $MyPlugin = $GLOBALS["G_PluginManager"]->GetPlugin("GoogleMaps");

        $MetadataField = $MyPlugin->ConfigSetting("DefaultPointField");

        if ($MetadataField != "" )
        {
            $Schema = new MetadataSchema();
            $Field = $Schema->GetField($MetadataField);
            $DBFieldName = $Field->DBFieldName();

            if ($Field->Type() == MetadataSchema::MDFTYPE_POINT)
            {
                # Data is lat/long encoded in a point field
                $DB->Query(
                    "SELECT ResourceId, "
                    ."`".$DBFieldName."X` AS Y,"
                    ."`".$DBFieldName."Y` AS X FROM Resources "
                    ."WHERE "
                    ."`".$DBFieldName."Y` IS NOT NULL AND "
                    ."`".$DBFieldName."X` IS NOT NULL");
                $Points = $DB->FetchRows();

                foreach ($Points as $Point)
                {
                    $rc []= array(
                        $Point["X"],
                        $Point["Y"],
                        $Point["ResourceId"],
                        "8F00FF", "", "000000"
                        );
                }
            }
            else
            {
                # Data is an address which needs to be geocoded:
                $DB->Query(
                    "SELECT ResourceId, `".$DBFieldName."` AS Addr FROM Resources "
                    ."WHERE `".$DBFieldName."` IS NOT NULL");
                $Addresses = $DB->FetchRows();

                foreach ($Addresses as $Address)
                {
                    $GeoData = $MyPlugin->Geocode($Address["Addr"]);
                    if ($GeoData !== NULL)
                    {
                        $rc []= array(
                            $GeoData["Lng"],
                            $GeoData["Lat"],
                            $Address["ResourceId"],
                            "8F00FF","","000000"
                            );
                    }
                }
            }
        }
        else
        {
            $SqlQuery =  $MyPlugin->ConfigSetting("DefaultSqlQuery");

            if ($SqlQuery != NULL && !ContainsDangerousSQL($SqlQuery) )
            {
                $DB->Query($SqlQuery);
                while ($Row = $DB->FetchRow())
                {
                    $rc []= array(
                        $Row["Longitude"],
                        $Row["Latitude"],
                        $Row["ResourceId"],
                        isset($Row["MarkerColor"])? $Row["MarkerColor"] : "8F00FF" ,
                        isset($Row["MarkerLabel"])? $Row["MarkerLabel"] : "",
                        isset($Row["LabelColor"]) ? $Row["LabelColor"]  : "000000"
                        );
                }
            }
        }

        return $rc;
    }

    /**
    * Provides a default detail provider containing the title, description, and
    * a link to the full record of a resource.
    * @param int $ResourceId ID of the resource for which to provide details.
    */
    public static function DefaultDetailProvider($ResourceId)
    {
        $Resource = new Resource($ResourceId);

        if ($Resource->Status() != 1)
        {
            print("ERROR: Invalid ResourceId\n");
        }
        else
        {
            print(
                '<h1>'.$Resource->GetMapped("Title").'</h1>'
                .'<p>'.$Resource->GetMapped("Description").'</p>'
                .'<p><a href="index.php?P=FullRecord&amp;ID='.$Resource->Id().'">'
                .'(more information)</a></p>'
                );
        }
    }

    /**
    * Generate the file path string for the cached KML file given the point and
    * detail provider hashes.
    * @param string $PointProviderHash Point provider hash.
    * @param string $DetailProviderHash Detail provider hash
    * @return Path to the cached KML file.
    */
    protected function GetKmlFilePath($PointProviderHash, $DetailProviderHash)
    {
        $CachePath = $this->GetKmlCachePath();
        $FileName = $PointProviderHash . "_" .$DetailProviderHash . ".xml";
        $FilePath = $CachePath . "/" . $FileName;

        return $FilePath;
    }

    /**
    * Get the path of the KML cache directory.
    * @return Returns the path of the KML cache directory.
    */
    protected function GetKmlCachePath()
    {
        return $this->GetCachePath() . "/kml";
    }

    /**
    * Get the path of the cache directory.
    * @return Returns the path of the cache directory.
    */
    protected function GetCachePath()
    {
        return getcwd() . "/local/data/caches/GoogleMaps";
    }

    /**
    * Make sure the cache directories exist and are usable, creating them if
    * necessary.
    * @return Returns a string if there's an error and NULL otherwise.
    */
    protected function CheckCacheDirectory()
    {
        $CachePath = $this->GetCachePath();

        # the cache directory doesn't exist, try to create it
        if (!file_exists($CachePath))
        {
            $Result = @mkdir($CachePath, 0777, TRUE);

            if (FALSE === $Result)
            {
                return "The cache directory could not be created.";
            }
        }

        # exists, but is not a directory
        if (!is_dir($CachePath))
        {
            return "(".$CachePath.") is not a directory.";
        }

        # exists and is a directory, but is not writeable
        if (!is_writeable($CachePath))
        {
            return "The cache directory is not writeable.";
        }

        $KmlPath = $this->GetKmlCachePath();

        # the KML cache directory doesn't exist, try to create it
        if (!file_exists($KmlPath))
        {
            $Result = @mkdir($KmlPath);

            if (FALSE === $Result)
            {
                return "The KML cache directory could not be created.";
            }
        }

        # exists, but is not a directory
        if (!is_dir($KmlPath))
        {
            return "(".$KmlPath.") is not a directory.";
        }

        # exists and is a directory, but is not writeable
        if (!is_writeable($KmlPath))
        {
            return "The KML cache directory is not writeable.";
        }

        return NULL;
    }

}
