org.geowebcache.service.kml.KMLService.java Source code

Java tutorial

Introduction

Here is the source code for org.geowebcache.service.kml.KMLService.java

Source

/**
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Lesser General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 *  This program is distributed in the hope that it will be useful,
 *  but WITHOUT ANY WARRANTY; without even the implied warranty of
 *  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 *  GNU General Public License for more details.
 *
 *  You should have received a copy of the GNU Lesser General Public License
 *  along with this program.  If not, see <http://www.gnu.org/licenses/>.
 * 
 * @author Arne Kepp, The Open Planning Project, Copyright 2008
 */
package org.geowebcache.service.kml;

import java.io.IOException;
import java.net.URLDecoder;
import java.util.Arrays;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.geowebcache.GeoWebCacheException;
import org.geowebcache.conveyor.Conveyor;
import org.geowebcache.conveyor.ConveyorKMLTile;
import org.geowebcache.conveyor.ConveyorTile;
import org.geowebcache.grid.BoundingBox;
import org.geowebcache.grid.GridSetBroker;
import org.geowebcache.grid.GridSubset;
import org.geowebcache.grid.OutsideCoverageException;
import org.geowebcache.io.ByteArrayResource;
import org.geowebcache.layer.TileLayer;
import org.geowebcache.layer.TileLayerDispatcher;
import org.geowebcache.mime.ImageMime;
import org.geowebcache.mime.MimeType;
import org.geowebcache.mime.XMLMime;
import org.geowebcache.service.Service;
import org.geowebcache.service.ServiceException;
import org.geowebcache.stats.RuntimeStats;
import org.geowebcache.storage.StorageBroker;

/**
 * The flow through this service is roughly as follows:
 * 
 * 1) getTile() - inital parsing 2a) Tile completed by layer (raster) 2b) handleRequest(), Tile
 * completed by service 3a) SuperOverlay -> handleSuperOverlay(); -> generates required KML 3b)
 * Overlay (possibly KMZ with packaged data) -> handleOverlay() -> check cache, or call
 * createOverlay and package
 */
public class KMLService extends Service {
    private static Log log = LogFactory.getLog(org.geowebcache.service.kml.KMLService.class);

    public static final String SERVICE_KML = "kml";

    public static final String HINT_DEBUGGRID = "debuggrid";

    public static final String HINT_SITEMAP_LAYER = "sitemap";

    public static final String HINT_SITEMAP_GLOBAL = "sitemap_global";

    private StorageBroker sb;

    private TileLayerDispatcher tld;

    private GridSetBroker gsb;

    private RuntimeStats stats;

    /**
     * Protected no-argument constructor to allow run-time instrumentation
     */
    protected KMLService() {
        super(SERVICE_KML);
    }

    public KMLService(StorageBroker sb, TileLayerDispatcher tld, GridSetBroker gsb, RuntimeStats stats) {
        super(SERVICE_KML);

        this.sb = sb;
        this.tld = tld;
        this.gsb = gsb;
        this.stats = stats;
    }

    /**
     * Parses the pathinfo part of an HttpServletRequest into the three components it is (hopefully)
     * made up of.
     * 
     * Example 1: /kml/layername.format.extension (superoverlay) Example 2:
     * /kml/layername/tilekey.format.extension (kml or kmz, overlay) Example 3:
     * /kml/layername/tilekey.format (data)
     * 
     * @param pathInfo
     * @return {layername, tilekey, format, wrapperformat}
     */
    protected static String[] parseRequest(String pathInfo) {
        String[] retStrs = new String[4];

        String[] splitStr = pathInfo.split("/");

        // Deal with the extension
        String filename = splitStr[splitStr.length - 1];
        int extOfst = filename.lastIndexOf(".");
        // This finds the last extension (wrapper)
        String lastExtension = filename.substring(extOfst + 1, filename.length());

        // Looks for a payload format
        int typeExtOfst = filename.lastIndexOf(".", extOfst - 1);

        if (typeExtOfst > 0) {
            // Wrapper with two extensions
            retStrs[2] = filename.substring(typeExtOfst + 1, extOfst);
            retStrs[3] = lastExtension;
        } else {
            // Regular tile
            retStrs[2] = lastExtension;
            retStrs[3] = null;
            typeExtOfst = extOfst;
        }

        // Three types of requests
        String ext = splitStr[splitStr.length - 2];
        if (ext.equalsIgnoreCase("kml") || ext.equalsIgnoreCase("kmz")) {
            // layername.km[z|l] or layername.format.km[z|l]
            retStrs[0] = filename.substring(0, typeExtOfst);
            retStrs[1] = "";
        } else {
            // layername/key.format.km[z|l]
            retStrs[0] = splitStr[splitStr.length - 2];
            retStrs[1] = filename.substring(0, typeExtOfst);
        }

        return retStrs;
    }

    /**
     * This is the entry point, this is where we tell the dispatcher whether we want to handle the
     * request or forward it to the tile layer (just a PNG).
     */
    public ConveyorTile getConveyor(HttpServletRequest request, HttpServletResponse response)
            throws GeoWebCacheException {
        String[] parsed = null;
        try {
            // TODO The container is supposed to handle the decoding prior
            // to returning but in Eclipse / Jetty this does not hold true
            parsed = parseRequest(URLDecoder.decode(request.getPathInfo(), "UTF-8"));
        } catch (Exception e) {
            throw new ServiceException("Unable to parse KML request : " + e.getMessage());
        }

        long[] gridLoc = { -1, -1, -1 };

        // Do we have a key for the grid location?
        if (parsed[1].length() > 0) {
            gridLoc = KMLService.parseGridLocString(parsed[1]);
        }

        ConveyorKMLTile tile = new ConveyorKMLTile(sb, parsed[0], gsb.WORLD_EPSG4326.getName(), gridLoc,
                MimeType.createFromExtension(parsed[2]), null, request, response);

        // Sitemap index ? kml/sitemap.xml
        if (parsed[0].equalsIgnoreCase("sitemap") && parsed[2].equalsIgnoreCase("xml")) {
            tile.setHint(HINT_SITEMAP_GLOBAL);
            String tmpUrl = urlPrefix(request.getRequestURL().toString(), parsed);
            tile.setUrlPrefix(tmpUrl.substring(0, tmpUrl.length() - "sitemap".length()));
            tile.setRequestHandler(ConveyorTile.RequestHandler.SERVICE);
            return tile;
        }

        // Sitemap ? kml/prefix:layername/sitemap.xml
        if (parsed[1].equalsIgnoreCase(HINT_SITEMAP_LAYER)) {
            tile.setHint(HINT_SITEMAP_LAYER);
            tile.setUrlPrefix(urlPrefix(request.getRequestURL().toString(), parsed));
            tile.setRequestHandler(ConveyorTile.RequestHandler.SERVICE);
            return tile;
        }

        // Is this a [super]overlay?
        if (parsed[3] != null) {
            tile.setRequestHandler(ConveyorTile.RequestHandler.SERVICE);
            tile.setUrlPrefix(urlPrefix(request.getRequestURL().toString(), parsed));
            tile.setWrapperMimeType(MimeType.createFromExtension(parsed[3]));
        }

        // Debug layer?
        if (tile.getLayerId().equalsIgnoreCase(KMLDebugGridLayer.LAYERNAME)) {
            tile.setHint(HINT_DEBUGGRID);
            tile.setRequestHandler(ConveyorTile.RequestHandler.SERVICE);
        }

        // System.out.println(Arrays.toString(tile.getTileIndex()) + " " +
        // tile.servletReq.getHeader("referer"));
        return tile;
    }

    /**
     * Let the service handle the request
     */
    public void handleRequest(Conveyor conv) throws GeoWebCacheException {

        ConveyorKMLTile tile = (ConveyorKMLTile) conv;

        TileLayer layer;
        if (tile.getHint() == HINT_DEBUGGRID) {
            layer = KMLDebugGridLayer.getInstance();

            // Generate random tile for debugging
            if (tile.getWrapperMimeType() == null) {
                tile.setTileLayer(layer);

                try {
                    layer.getTile(tile);
                } catch (Exception e) {
                    e.printStackTrace();
                }

                String mimeStr = getMimeTypeOverride(tile);
                writeTileResponse(tile, false, stats, mimeStr);
                return;
            }
        } else if (tile.getHint() == HINT_SITEMAP_GLOBAL) {
            layer = null;
        } else {
            layer = tld.getTileLayer(tile.getLayerId());

            if (layer == null) {
                throw new ServiceException("No layer provided, request parsed to: " + tile.getLayerId());
            }
        }
        tile.setTileLayer(layer);

        // if(tile.getHint() == HINT_SITEMAP_LAYER || tile.getHint() == HINT_SITEMAP_GLOBAL) {
        // KMLSiteMap sm = new KMLSiteMap(tile,tld);
        // try {
        // sm.write();
        // } catch (IOException ioe) {
        // throw new GeoWebCacheException("Unable to write sitemap: " + ioe.getMessage());
        // }
        // return;
        // }

        if (tile.getTileIndex()[2] == -1) {
            // No tile index -> super overlay
            if (log.isDebugEnabled()) {
                log.debug("Request for super overlay for " + tile.getLayerId() + " received");
            }
            handleSuperOverlay(tile);
        } else {
            if (log.isDebugEnabled()) {
                log.debug("Request for overlay for " + tile.getLayerId());
            }
            handleOverlay(tile);
        }
    }

    private static String urlPrefix(String requestUrl, String[] parsed) {
        int endOffset = requestUrl.length() - parsed[1].length() - parsed[2].length();

        // Also remove the second extension and the dot
        if (parsed.length > 3 && parsed[3] != null) {
            endOffset -= parsed[3].length() + 1;
        }

        return new String(requestUrl.substring(0, endOffset - 1));
    }

    /**
     * Creates a superoverlay, ie. a short description and network links to the first overlays.
     * 
     * @param tile
     */
    private void handleSuperOverlay(ConveyorKMLTile tile) throws GeoWebCacheException {
        TileLayer layer = tile.getLayer();

        GridSubset gridSubset = tile.getGridSubset();

        // int srsIdx = layer.getSRSIndex(srs);
        BoundingBox bbox = gridSubset.getCoverageBestFitBounds();

        String formatExtension = "." + tile.getMimeType().getFileExtension();
        if (tile.getWrapperMimeType() != null) {
            formatExtension = formatExtension + "." + tile.getWrapperMimeType().getFileExtension();
        }

        long[] gridRect = gridSubset.getCoverageBestFit();
        String networkLinks = null;

        // Check whether we need two tiles for world bounds or not
        if (gridRect[4] > 0 && (gridRect[2] != gridRect[0] || gridRect[3] != gridRect[1])) {
            throw new GeoWebCacheException(
                    layer.getName() + " (" + bbox.toString() + ") is too big for the sub grid set for "
                            + gridSubset.getName() + ", allow for smaller zoom levels.");
        } else if (gridRect[0] != gridRect[2]) {
            long[] gridLocWest = { 0, 0, 0 };
            long[] gridLocEast = { 1, 0, 0 };

            BoundingBox bboxWest = new BoundingBox(bbox.getMinX(), bbox.getMinY(), 0.0, bbox.getMaxY());
            BoundingBox bboxEast = new BoundingBox(0.0, bbox.getMinY(), bbox.getMaxX(), bbox.getMaxY());

            networkLinks = superOverlayNetworLink(layer.getName() + " West", bboxWest,
                    tile.getUrlPrefix() + "/" + gridLocString(gridLocWest) + formatExtension)
                    + superOverlayNetworLink(layer.getName() + " East", bboxEast,
                            tile.getUrlPrefix() + "/" + gridLocString(gridLocEast) + formatExtension);

        } else {
            long[] gridLoc = { gridRect[0], gridRect[1], gridRect[4] };

            networkLinks = superOverlayNetworLink(layer.getName(), bbox,
                    tile.getUrlPrefix() + "/" + gridLocString(gridLoc) + formatExtension);
        }

        String xml = KMLHeader() + "\n<Folder>" + getLookAt(bbox) + networkLinks + "\n</Folder>" + "\n</kml>\n";

        tile.setBlob(new ByteArrayResource(xml.getBytes()));
        tile.setMimeType(XMLMime.kml);
        tile.setStatus(200);
        String mimeStr = getMimeTypeOverride(tile);
        writeTileResponse(tile, true, stats, mimeStr);
    }

    /**
     * Creates a network link to the first tile in the pyramid
     * 
     * @param superString
     * @param bbox
     * @param url
     * @return
     */
    private static String superOverlayNetworLink(String superString, BoundingBox bbox, String url) {
        String xml = "\n<NetworkLink><name>Super-overlay: " + superString + "</name>" + "\n<Region>\n"
                + bbox.toKMLLatLonAltBox() + "\n<Lod><minLodPixels>128</minLodPixels>"
                + "\n<maxLodPixels>-1</maxLodPixels></Lod>" + "\n</Region>" + "\n<Link><href>" + url + "</href>"
                + "\n<viewRefreshMode>onRegion</viewRefreshMode>" + "\n</Link>" + "\n</NetworkLink>";

        return xml;
    }

    protected static String gridLocString(long[] gridLoc) {
        return "x" + gridLoc[0] + "y" + gridLoc[1] + "z" + gridLoc[2];
    }

    protected static long[] parseGridLocString(String key) throws ServiceException {
        // format should be x<x>y<y>z<z>

        long[] ret = { -1, -1, -1 };

        int yloc = key.indexOf("y");
        int zloc = key.indexOf("z");

        if (yloc < 2 || zloc < 4) {
            return ret;
        }

        try {
            ret[0] = Long.parseLong(key.substring(1, yloc));
            ret[1] = Long.parseLong(key.substring(yloc + 1, zloc));
            ret[2] = Long.parseLong(key.substring(zloc + 1, key.length()));
        } catch (NumberFormatException nfe) {
            throw new ServiceException("Unable to parse " + key);
        } catch (StringIndexOutOfBoundsException sobe) {
            throw new ServiceException("Unable to parse " + key);
        }
        return ret;
    }

    /**
     * These are the main nodes in the KML hierarchy, each overlay contains a set of network links
     * (up to 4) that point to the overlays on the next level.
     * 
     * 1) KMZ: The cache will contain a zip with overlay and data
     * 
     * 2) KML: The cache will only contain the overlay itself, the overlay will cause a separate
     * tile request to get the data
     */
    private void handleOverlay(ConveyorKMLTile tile) throws GeoWebCacheException {

        TileLayer tileLayer = tile.getLayer();

        boolean packageData = false;
        if (tile.getWrapperMimeType() == XMLMime.kmz) {
            packageData = true;
        }

        // TODO The 1.1 branch doesn't have a good way of storing the archives.
        // For now we compress on every request

        // Did we get lucky?
        // TODO need to look into expiration here
        // if(tile.retrieve(-1)) {
        // writeResponse(tile,true);
        // return;
        // }

        // Sigh....
        if (!packageData) {
            String overlayXml = createOverlay(tile, false);
            tile.setBlob(new ByteArrayResource(overlayXml.getBytes()));
            tile.setStatus(200);
            // tileLayer.putTile(tile);
        } else {
            // Get the overlay
            String overlayXml = createOverlay(tile, true);

            // Get the data (cheat)
            try {
                tile.setWrapperMimeType(null);
                try {
                    tileLayer.getTile(tile);
                } catch (OutsideCoverageException oce) {
                    log.error("Out of bounds: " + Arrays.toString(tile.getTileIndex())
                            + " should never habe been linked to.");
                    throw oce;
                }
                tile.setWrapperMimeType(XMLMime.kmz);
            } catch (IOException ioe) {
                log.error(ioe.getMessage());
                ioe.printStackTrace();
                throw new ServiceException(ioe.getMessage());
            }

            byte[] zip = KMZHelper.createZippedKML(gridLocString(tile.getTileIndex()),
                    tile.getMimeType().getFileExtension(), overlayXml.getBytes(), tile.getBlob());

            tile.setBlob(new ByteArrayResource(zip));
            tile.setStatus(200);
            // tileLayer.putTile(tile);

        }

        String mimeStr = getMimeTypeOverride(tile);

        writeTileResponse(tile, true, stats, mimeStr);
    }

    private String getMimeTypeOverride(ConveyorKMLTile tile) {
        String mimeStr = null;
        if (tile.getWrapperMimeType() != null) {
            mimeStr = tile.getWrapperMimeType().getMimeType();
        }
        return mimeStr;
    }

    /**
     * Creates an overlay element: 1) Header 2) Network links to regions where we have more data 3)
     * Overlay (link to data) 4) Footer
     * 
     * @param tileLayer
     * @param urlStr
     * @param key
     * @param extension
     * @param formatExtension
     * @param isRaster
     * @param response
     * @return
     * @throws ServiceException
     */
    private static String createOverlay(ConveyorKMLTile tile, boolean isPackaged)
            throws ServiceException, GeoWebCacheException {
        boolean isRaster = (tile.getMimeType() instanceof ImageMime);

        TileLayer tileLayer = tile.getLayer();

        GridSubset gridSubset = tile.getGridSubset();

        long[] gridLoc = tile.getTileIndex();

        BoundingBox bbox = gridSubset.boundsFromIndex(gridLoc);

        String refreshTags = "";
        int refreshInterval = tileLayer.getExpireClients((int) gridLoc[2]);
        if (refreshInterval > 0) {
            refreshTags = "\n<refreshMode>onInterval</refreshMode>" + "\n<refreshInterval>" + refreshInterval
                    + "</refreshInterval>";
        }

        StringBuffer buf = new StringBuffer();

        // 1) Header
        boolean setMaxLod = false;
        if (isRaster && gridLoc[2] < gridSubset.getZoomStop()) {
            setMaxLod = true;
        }
        buf.append(createOverlayHeader(bbox, setMaxLod));

        buf.append("\n<!-- Network links to subtiles -->\n");
        // 2) Network links, only to tiles getCoverages();within bounds

        long[][] linkGridLocs = gridSubset.getSubGrid(gridLoc);

        // 3) Apply secondary filter against linking to empty tiles
        linkGridLocs = KMZHelper.filterGridLocs(tile.getStorageBroker(), tileLayer, gridSubset.getName(),
                tile.getMimeType(), linkGridLocs);

        // int moreData = 0;
        for (int i = 0; i < 4; i++) {
            // Only add this link if it is within the bounds
            if (linkGridLocs[i][2] > 0) {
                BoundingBox linkBbox = gridSubset.boundsFromIndex(linkGridLocs[i]);

                String gridLocStr = gridLocString(linkGridLocs[i]);

                // Always use absolute URLs for these
                String gridLocUrl = tile.getUrlPrefix() + gridLocStr + "." + tile.getMimeType().getFileExtension()
                        + "." + tile.getWrapperMimeType().getFileExtension();

                buf.append(createNetworkLinkElement(tileLayer, linkBbox, gridLocUrl, gridLocStr, -1, refreshTags));
                // moreData++;
            }
        }

        buf.append("\n<!-- Network link to actual content -->\n");

        // 5) Overlay, should be relative
        if (isRaster) {
            buf.append(createGroundOverLayElement(gridLoc, tile.getUrlPrefix(), bbox,
                    tile.getMimeType().getFileExtension(), refreshTags));
        } else {
            // KML
            String gridLocStr = gridLocString(gridLoc);
            String gridLocUrl = gridLocStr + "." + tile.getMimeType().getFileExtension();

            if (isPackaged) {
                gridLocUrl = "data_" + gridLocUrl;
            }

            int maxLodPixels = -1;
            if (tile.getLayer() instanceof KMLDebugGridLayer) {
                maxLodPixels = 385;
            }

            buf.append(
                    createNetworkLinkElement(tileLayer, bbox, gridLocUrl, gridLocStr, maxLodPixels, refreshTags));
        }

        // if(moreData > 0) {
        // xml += "</Document>\n<Document>"+moreDataIcon(bbox)+"</Document>\n";
        // } else {
        buf.append("</Document>\n</kml>");
        // }

        return buf.toString();
    }

    /**
     * This creates the header for the overlay
     * 
     * @param bbox
     * @return
     */
    private static String createOverlayHeader(BoundingBox bbox, boolean setMaxLod) {
        int maxLodPixels = -1;
        if (setMaxLod) {
            maxLodPixels = 385;
        }

        return KMLHeader() + "<Document>\n" + "<Region>\n" + bbox.toKMLLatLonAltBox()
                + "<Lod><minLodPixels>128</minLodPixels>" + "<maxLodPixels>" + Integer.toString(maxLodPixels)
                + "</maxLodPixels></Lod>\n" + "</Region>\n";
    }

    /**
     * For KML features / vector data OR for the next level
     * 
     * @param layer
     * @param urlStr
     * @param gridLoc
     * @param bbox
     * @param extension
     * @return
     */
    private static String createNetworkLinkElement(TileLayer layer, BoundingBox bbox, String gridLocUrl,
            String tileIdx, int maxLodPixels, String refreshTags) {

        String xml = "\n<NetworkLink>" + "\n<name>" + layer.getName() + "</name>" + "\n<Region>"
                + bbox.toKMLLatLonAltBox() + "\n<Lod><minLodPixels>128</minLodPixels>" + "<maxLodPixels>"
                + Integer.toString(maxLodPixels) + "</maxLodPixels></Lod>\n" + "</Region>" + "\n<Link>" + "\n<href>"
                + gridLocUrl + "</href>" + refreshTags + "\n<viewRefreshMode>onRegion</viewRefreshMode>"
                + "\n</Link>" + "\n</NetworkLink>\n";

        return xml;
    }

    /**
     * Used for linking to a raster image
     * 
     * @param gridLoc
     * @param urlStr
     * @param bbox
     * @param formatExtension
     * @return
     */
    private static String createGroundOverLayElement(long[] gridLoc, String urlStr, BoundingBox bbox,
            String formatExtension, String refreshTags) {

        String xml = "\n<GroundOverlay>" + "\n<drawOrder>" + gridLoc[2] + "</drawOrder>"

                + "\n<Icon>" + "\n<href>" + gridLocString(gridLoc) + "." + formatExtension + "</href>" + refreshTags
                + "\n</Icon>\n" + "\n<altitudeMode>clampToGround</altitudeMode>" + bbox.toKMLLatLonBox()
                + "\n</GroundOverlay>\n";

        return xml;
    }

    private static String getLookAt(BoundingBox bbox) {
        double lon1 = bbox.getMinX();
        double lat1 = bbox.getMinY();
        double lon2 = bbox.getMaxX();
        double lat2 = bbox.getMaxY();

        double R_EARTH = 6.371 * 1000000; // meters
        // double VIEWER_WIDTH = 22 * Math.PI / 180; // The field of view of the
        // // google maps camera, in
        // // radians
        double[] p1 = getRect(lon1, lat1, R_EARTH);
        double[] p2 = getRect(lon2, lat2, R_EARTH);
        double[] midpoint = new double[] { (p1[0] + p2[0]) / 2, (p1[1] + p2[1]) / 2, (p1[2] + p2[2]) / 2 };

        midpoint = getGeographic(midpoint[0], midpoint[1], midpoint[2]);

        // averaging the longitudes; using the rectangular coordinates makes the
        // calculated center tend toward the corner that's closer to the
        // equator.
        midpoint[0] = ((lon1 + lon2) / 2);

        double distance = distance(p1, p2);

        // double height = distance / (2 * Math.tan(VIEWER_WIDTH));

        return "<LookAt id=\"superoverlay\">" + "\n<longitude>" + ((lon1 + lon2) / 2) + "</longitude>"
                + "\n<latitude>" + midpoint[1] + "</latitude>" + "\n<altitude>0</altitude>"
                + "\n<heading>0</heading>" + "\n<tilt>0</tilt>" + "\n<range>" + distance + "</range>"
                + "\n<altitudeMode>clampToGround</altitudeMode>"
                // + "\n<!--kml:altitudeModeEnum:clampToGround, relativeToGround, absolute -->"
                + "\n</LookAt>\n";
    }

    private static String KMLHeader() {
        return "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n" + "<kml xmlns=\"http://www.opengis.net/kml/2.2\" "
                + "xmlns:xsi=\"http://www.w3.org/2001/XMLSchema-instance\" "
                + "xsi:schemaLocation=\"http://www.opengis.net/kml/2.2 "
                + "http://schemas.opengis.net/kml/2.2.0/ogckml22.xsd\">\n";
    }

    // private static String moreDataIcon(BBOX bbox){
    // return "<Region>\n" +
    // "<Lod><minLodPixels>128</minLodPixels>" +
    // "<maxLodPixels>512</maxLodPixels></Lod>\n" +
    // bbox.toKML() + "</Region>\n" +
    // "<ScreenOverlay><name>More data</name>" +
    // "<visibility>1</visibility>" +
    // "<open>1</open>" +
    // "<Icon><href>http://bbc.blueghost.co.uk/images/bbc_v2.png</href></Icon>" +
    // "<color>ffffffff</color>" +
    // "<drawOrder>0</drawOrder>" +
    // "<overlayXY x=\"1\" y=\"1\" xunits=\"fraction\" yunits=\"fraction\"/>" +
    // "<screenXY x=\"1\" y=\"1\" xunits=\"fraction\" yunits=\"fraction\"/>" +
    // "<rotationXY x=\"0\" y=\"0\" xunits=\"fraction\" yunits=\"fraction\"/>" +
    // "<size x=\"0\" y=\"0\" xunits=\"fraction\" yunits=\"fraction\"/>" +
    // "<rotation>0</rotation>" +
    // "</ScreenOverlay>";
    // }

    private static double[] getRect(double lat, double lon, double radius) {
        double theta = (90 - lat) * Math.PI / 180;
        double phi = (90 - lon) * Math.PI / 180;

        double x = radius * Math.sin(phi) * Math.cos(theta);
        double y = radius * Math.sin(phi) * Math.sin(theta);
        double z = radius * Math.cos(phi);
        return new double[] { x, y, z };
    }

    private static double[] getGeographic(double x, double y, double z) {
        double theta, phi, radius;
        radius = distance(new double[] { x, y, z }, new double[] { 0, 0, 0 });
        theta = Math.atan2(Math.sqrt(x * x + y * y), z);
        phi = Math.atan2(y, x);

        double lat = 90 - (theta * 180 / Math.PI);
        double lon = 90 - (phi * 180 / Math.PI);

        return new double[] { (lon > 180 ? lon - 360 : lon), lat, radius };
    }

    private static double distance(double[] p1, double[] p2) {
        double dx = p1[0] - p2[0];
        double dy = p1[1] - p2[1];
        double dz = p1[2] - p2[2];
        return Math.sqrt(dx * dx + dy * dy + dz * dz);
    }
}