/** * 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); } }