Google Maps: Large KML and Tiles

In: General

31 Dec 2009

Last year I wrote an application to highlight media outlets and their reach (coverage of media outlets), selecting regions within the UK and highlighting aspects of a map. This had many issues where by hitting performance problems of rendering within browsers and also limitations of converting KML to tiles via google. A list of these limitations are:

  1. Timeouts from google on large KML files.
  2. Responsiveness of servers to deliver KML files to google.
  3. Max KML size (Even when gzipped)
  4. 500 Errors from google
  5. Transparency within IE
  6. ….

Some of these limits have since been increased by google and are documented.

Maximum fetched file size (raw KML, raw GeoRSS, or compressed KMZ) 3MB
Maximum uncompressed KML file size 10MB
Maximum number of Network Links 10
Maximum number of total document-wide features 1,000

In order to alleviate these issues I ended up with the following

  • Caching KML files to avoid latency on a expensive database lookups/response.
  • Chunking the response into 250 records and writing to individual static KML files. (Files would become very large and google would time out retrieving data sets).
  • Proxying googles tiles after they had been converted from KML to images and caching them locally on our servers and then applying the overlays from our servers once merged

So depending on the depth (zoom) of the map and the area selected as well the volume of data, it would either use tiles or googles KML directly (Increased functionality).

In order to have greater control over the spatial data within our database we split this into areas, regions, and sub_regions, which held lookups to postcodes, towns and spatial data itself (There are a lot of discrepancies over outlines of maps).

Left hand menu:

<ul style="display: block;">
	<li id="East"><a href="#" onclick="loadTilesFromGeoXML('|1|'); return false;">East</a>
		<ul style="display: none;">
			<li><a href="#" onclick="loadTilesFromGeoXML('|1|6'); return false;">Bedfordshire</a></li>
			<li><a href="#" onclick="loadTilesFromGeoXML('|1|18'); return false;">Cambridgeshire</a></li>
			...
		</ul>
	</li>
</ul>

Javascript to locate tiles

  function loadTilesFromGeoXML(entity_id) {
    // Matches database record ids that are mapped to spatial data within MySQL
    mapTownsId = entity_id.toString().split('|')[0];
    mapRegionsId = entity_id.toString().split('|')[1];
    mapSubRegionsId = entity_id.toString().split('|')[2];
    locationUrl ='map_towns_id='+mapTownsId+'&map_regions_id='+mapRegionsId+'&map_sub_regions_id='+mapSubRegionsId;

    var cc = map.fromLatLngToDivPixel(map.getCenter());
    map.setZoom(1);

    // Request URL to cached titles links
    geoXMLUrl = '/ajax/mapping/get/overlays/region?'+locationUrl;
    geoXMLUrl+='&format=JSON&method=getLinks&x='+cc.x+'&y='+cc.y+'&zoom='+map.getZoom();

    // tileUrlTemplate: 'http://domain.com/maps/proxy/regions/?url=http%3A%2F%2Fdomain.com/ajax/mapping/get/cache/?filename=.1.6.0&x={X}&y={Y}&zoom={Z}',

    $.getJSON(geoXMLUrl, function(data) {
      $.each(data, function(i,link) {
        kmlLinks+=encodeURIComponent(link)+',';
      });

      // Builds the location for tiles to be mapped
      tileUrlTemplate = '/maps/proxy/regions/?url='+kmlLinks+'&x={X}&y={Y}&zoom={Z}';
      var tileLayerOverlay = new GTileLayerOverlay(
        new GTileLayer(null, null, null, {
          tileUrlTemplate: tileUrlTemplate,
          isPng:true,
          opacity:1.0
        })
      );
      if (debug) GLog.writeUrl('/maps/proxy/regions/?url='+kmlLinks+'&x={X}&y={Y}&zoom={Z}');
      map.addOverlay(tileLayerOverlay);
    });
  }

Response whilst retrieving links (if cached)

The code behind this simply caches the KML files, if it does not exist, otherwise attempts to create it and also outputs a json request with the files matching the sequence and globs for any files with a similar pattern, all files are suffixed with their page number.

["/ajax/mapping/get/cache/?filename=.1..0&x=250&y=225&zoom=5","/ajax/mapping/get/cache/?filename=.1..1&x=250&y=225&zoom=5"]

Proxying googles tiles and merging the layer ids

    $kmlUrls = urlencode($_GET['url']);
    $cachePath = dirname(__FILE__).'/cache.maps/tiles/';

    $cachedFiles = array_filter(explode(',',rawurldecode($kmlUrls)));
    $hash = sha1(rawurldecode($kmlUrls).".w{$_GET['w']}.h{$_GET['h']}.x{$_GET['x']}.y{$_GET['y']}.{$_GET['zoom']}");
    $cachePath.="{$_GET['x']}.{$_GET['y']}/{$_GET['zoom']}/";
    if (!is_dir($cachePath)) {
      @mkdir($cachePath, 0777, true);
    }

    // Returns image if cached already and aggregated.
    if (file_exists($path = $cachePath.$hash)) {
      header('Content-Type: image/png');
      $fp = fopen($path, 'rb');
      fpassthru($fp);
    }

    // Extract layer id's from KML files that are to be merged.
    $layerIds = array();
    foreach( $cachedFiles AS $kmlFile) {
      $kmlFile="http://{$_SERVER['HTTP_HOST']}{$kmlFile}";

      $url = "http://maps.google.com/maps/gx?q={$kmlFile}&callback=_xdc_._1fsue7g2w";
      @$c = file_get_contents($url);
      if (!$c)
        throw new Exception("Failed to request {$url} - {$c}");
      preg_match_all('/layer_id:"kml:(.*)"/i', $c, $matches);
      if (count($matches)>0 && isset($matches[1][0])) {
        $layerIds[] = "kml:{$matches[1][0]}";
      }
    }

    // Cache locally.
    if (count($layerIds)>0) {
      header('Content-Type: image/png');
      // Aggregate layers into a single image
      $link = "http://mlt0.google.com/mapslt?lyrs=" . implode(',',$layerIds);
      $link.="&x={$_GET['x']}&y={$_GET['y']}&z={$_GET['zoom']}&w={$_GET['w']}&h={$_GET['h']}&source=maps_api";
      echo $c = file_get_contents($link);
      @file_put_contents($path, $c);
    } else {
      // Output 1x1 png
      header('Content-Type: image/png');
      echo base64_decode('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVQIHWNgAAIAAAUAAY27m/MAAAAASUVORK5CYII=');
    }
  }

Paging GeoXML loading

    function loadGeoXMLPaged(geoXMLUrl) {
      var cc = map.fromLatLngToDivPixel(map.getCenter());
      geoXMLUrl+='&format=JSON&method=getLinks&x='+cc.x+'&y='+cc.y+'&zoom='+map.getZoom();

      if (debug) GLog.writeUrl(geoXMLUrl);

      $.getJSON(geoXMLUrl, function(data) {
	  geoXmlPager = data;
          loadGeoXmlPage();
        });
      }

      var timeoutPID = null;

      function loadGeoXmlPage(){
  	if (data = geoXmlPager.pop()){
	 if (debug)
            GLog.writeUrl(BASE_URL+data);

	 geoXmlStack.push(new GGeoXml(BASE_URL+data));
	 map.addOverlay(geoXmlStack[geoXmlStack.length - 1]);

         GEvent.addListener(geoXmlStack[geoXmlStack.length - 1],"load",function() {
	  timeoutPID = setTimeout("loadGeoXmlPage()", 500);
         });
	}else{
          clearTimeout(timeoutPID);
	  map.setZoom(map.getBoundsZoomLevel(bounds));
          map.setCenter(bounds.getCenter());
          try {
            geoXmlStack[geoXmlStack.length - 1].gotoDefaultViewport(map);
          } catch(e) {}
	}
      }

All the code above has been modified slightly to make it applicable to others, however don’t accept raw input as its simply an example.

Comment Form

About this blog

I have been a developer for roughly 10 years and have worked with an extensive range of technologies. Whilst working for relatively small companies, I have worked with all aspects of the development life cycle, which has given me a broad and in-depth experience.