/* (c) 2014 Open Source Geospatial Foundation - all rights reserved * (c) 2001 - 2013 OpenPlans * This code is licensed under the GPL 2.0 license, available at the root * application directory. */ package org.geoserver.wms.map; import java.awt.Point; import java.awt.geom.Point2D; import java.awt.image.RenderedImage; import java.util.HashSet; import java.util.Iterator; import java.util.List; import java.util.Map; import java.util.Map.Entry; import java.util.Set; import java.util.WeakHashMap; import org.apache.commons.lang.builder.EqualsBuilder; import org.apache.commons.lang.builder.HashCodeBuilder; import org.geoserver.config.ConfigurationListenerAdapter; import org.geoserver.config.GeoServer; import org.geoserver.config.GeoServerInfo; import org.geoserver.config.ServiceInfo; import org.geoserver.config.impl.GeoServerLifecycleHandler; import org.geoserver.platform.ServiceException; import org.geoserver.wfs.TransactionEvent; import org.geoserver.wfs.TransactionListener; import org.geoserver.wfs.WFSException; import org.geoserver.wms.GetMapRequest; import org.geotools.geometry.jts.ReferencedEnvelope; import org.geotools.referencing.CRS; import org.geotools.referencing.CRS.AxisOrder; import org.geotools.util.CanonicalSet; import com.vividsolutions.jts.geom.Envelope; public class QuickTileCache implements TransactionListener, GeoServerLifecycleHandler { /** * Set of parameters that we can ignore, since they do not define a map, are either unrelated, * or define the tiling instead */ private static final Set ignoredParameters; static { ignoredParameters = new HashSet(); ignoredParameters.add("REQUEST"); ignoredParameters.add("TILED"); ignoredParameters.add("BBOX"); ignoredParameters.add("WIDTH"); ignoredParameters.add("HEIGHT"); ignoredParameters.add("SERVICE"); ignoredParameters.add("VERSION"); ignoredParameters.add("EXCEPTIONS"); } /** * Canonicalizer used to return the same object when two threads ask for the same meta-tile */ private CanonicalSet<MetaTileKey> metaTileKeys = CanonicalSet.newInstance(MetaTileKey.class); private WeakHashMap tileCache = new WeakHashMap(); public QuickTileCache(GeoServer geoServer) { geoServer.addListener(new ConfigurationListenerAdapter() { public void handleGlobalChange(GeoServerInfo global, List<String> propertyNames, List<Object> oldValues, List<Object> newValues) { tileCache.clear(); } public void handleServiceChange(ServiceInfo service, List<String> propertyNames, List<Object> oldValues, List<Object> newValues) { tileCache.clear(); } public void reloaded() { tileCache.clear(); } }); } /** * For testing only */ QuickTileCache() { } /** * Given a tiled request, builds a key that can be used to access the cache looking for a * specific meta-tile, and also as a synchronization tool to avoid multiple requests to trigger * parallel computation of the same meta-tile * * @param request * */ public MetaTileKey getMetaTileKey(GetMapRequest request) { String mapDefinition = buildMapDefinition(request.getRawKvp()); ReferencedEnvelope bbox = new ReferencedEnvelope(request.getBbox(), request.getCrs()); Point2D origin = request.getTilesOrigin(); if(CRS.getAxisOrder(request.getCrs()) == AxisOrder.NORTH_EAST) { try { bbox = new ReferencedEnvelope(bbox.getMinY(), bbox.getMaxY(), bbox.getMinX(), bbox.getMaxX(), CRS.decode("EPSG:" + CRS.lookupEpsgCode(request.getCrs(), false))); origin = new Point2D.Double(origin.getY(), origin.getX()); } catch(Exception e) { throw new ServiceException("Failed to bring the bbox back in a EN order", e); } } MapKey mapKey = new MapKey(mapDefinition, normalize(bbox.getWidth() / request.getWidth()), origin); Point tileCoords = getTileCoordinates(bbox, origin); Point metaTileCoords = getMetaTileCoordinates(tileCoords); ReferencedEnvelope metaTileEnvelope = getMetaTileEnvelope(bbox, tileCoords, metaTileCoords); MetaTileKey key = new MetaTileKey(mapKey, metaTileCoords, metaTileEnvelope); // since this will be used for thread synchronization, we have to make // sure two thread asking for the same meta tile will get the same key // object return metaTileKeys.unique(key); } private ReferencedEnvelope getMetaTileEnvelope(ReferencedEnvelope bbox, Point tileCoords, Point metaTileCoords) { double minx = bbox.getMinX() + (metaTileCoords.x - tileCoords.x) * bbox.getWidth(); double miny = bbox.getMinY() + (metaTileCoords.y - tileCoords.y) * bbox.getHeight(); double maxx = minx + bbox.getWidth() * 3; double maxy = miny + bbox.getHeight() * 3; return new ReferencedEnvelope(minx, maxx, miny, maxy, bbox.getCoordinateReferenceSystem()); } /** * Given a tile, returns the coordinates of the meta-tile that contains it (where the meta-tile * coordinate is the coordinate of its lower left subtile) * * @param tileCoords * */ Point getMetaTileCoordinates(Point tileCoords) { int x = tileCoords.x; int y = tileCoords.y; int rx = x % 3; int ry = y % 3; int mtx = (rx == 0) ? x : ((x >= 0) ? (x - rx) : (x - 3 - rx)); int mty = (ry == 0) ? y : ((y >= 0) ? (y - ry) : (y - 3 - ry)); return new Point(mtx, mty); } /** * Given an envelope and origin, find the tile coordinate (row,col) * * @param env * @param origin * */ Point getTileCoordinates(Envelope env, Point2D origin) { // this was using the low left corner and Math.round, but turned // out to be fragile when fairly zoomed in. Using the tile center // and then flooring the division seems to work much more reliably. double centerx = env.getMinX() + env.getWidth() / 2; double centery = env.getMinY() + env.getHeight() / 2; int x = (int) Math.floor((centerx - origin.getX()) / env.getWidth()); int y = (int) Math.floor((centery - origin.getY()) / env.getWidth()); return new Point(x, y); } /** * Given an envelope and the metatile envelope, locate the tile inside the metatile * * @param env * @param origin * */ Point getTileOffsetsInMeta(Envelope bbox, Envelope metatileBox) { // compute using local coordinates, the previous math was using global one that // broke at zoom level 23-24 in the global mercator projection (yes, at scale 1:33) double dx = bbox.getMinX() - metatileBox.getMinX(); double dy = bbox.getMinY() - metatileBox.getMinY(); int x = (int) Math.round(dx / bbox.getWidth()); int y = (int) Math.round(dy / bbox.getHeight()); return new Point(x, y); } /** * This is tricky. We need to have doubles that can be compared by equality because resolution * and origin are doubles, and are part of a hashmap key, so we have to normalize them somehow, * in order to make the little differences disappear. Here we take the mantissa, which is made * of 52 bits, and throw away the 20 more significant ones, which means we're dealing with 12 * significant decimal digits (2^40 -> more or less one billion million). See also <a * href="http://en.wikipedia.org/wiki/IEEE_754">IEEE 754</a> on Wikipedia. * * @param d * */ static double normalize(double d) { if (Double.isInfinite(d) || Double.isNaN(d)) { return d; } return Math.round(d * 10e6) / 10e6; } /** * Turns the request back into a sort of GET request (not url-encoded) for fast comparison * * @param map * */ private String buildMapDefinition(Map<String, String> map) { StringBuffer sb = new StringBuffer(); Entry<String, String> en; String paramName; for (Iterator<Map.Entry<String, String>> it = map.entrySet().iterator(); it.hasNext();) { en = it.next(); paramName = en.getKey(); if (ignoredParameters.contains(paramName.toUpperCase())) { continue; } // we don't have multi-valued parameters afaik, otherwise we would // have to use getParameterValues and deal with the returned array sb.append(paramName).append('=').append(en.getValue()); if (it.hasNext()) { sb.append('&'); } } return sb.toString(); } /** * Key defining a tiling layer in a map */ static class MapKey { String mapDefinition; double resolution; Point2D origin; public MapKey(String mapDefinition, double resolution, Point2D origin) { super(); this.mapDefinition = mapDefinition; this.resolution = resolution; this.origin = origin; } public int hashCode() { return new HashCodeBuilder().append(mapDefinition).append(resolution) .append(resolution).append(origin).toHashCode(); } public boolean equals(Object obj) { if (!(obj instanceof MapKey)) { return false; } MapKey other = (MapKey) obj; return new EqualsBuilder().append(mapDefinition, other.mapDefinition) .append(resolution, other.resolution).append(origin, other.origin).isEquals(); } public String toString() { return mapDefinition + "\nw:" + "\nresolution:" + resolution + "\norig:" + origin.getX() + "," + origin.getY(); } } /** * Key that identifies a certain meta-tile in a tiled map layer */ static class MetaTileKey { MapKey mapKey; Point metaTileCoords; ReferencedEnvelope metaTileEnvelope; public MetaTileKey(MapKey mapKey, Point metaTileCoords, ReferencedEnvelope metaTileEnvelope) { super(); this.mapKey = mapKey; this.metaTileCoords = metaTileCoords; this.metaTileEnvelope = metaTileEnvelope; } public ReferencedEnvelope getMetaTileEnvelope() { // This old code proved to be too much unstable, numerically wise, to be used // when very much zoomed in, so we moved to a local meta tile envelope computation // based on the requested tile bounds instead // double minx = mapKey.origin.getX() + (mapKey.resolution * 256 * metaTileCoords.x); // double miny = mapKey.origin.getY() + (mapKey.resolution * 256 * metaTileCoords.y); // // return new Envelope(minx, minx + (mapKey.resolution * 256 * 3), miny, miny // + (mapKey.resolution * 256 * 3)); return metaTileEnvelope; } public int hashCode() { return new HashCodeBuilder().append(mapKey).append(metaTileCoords).toHashCode(); } public boolean equals(Object obj) { if (!(obj instanceof MetaTileKey)) { return false; } MetaTileKey other = (MetaTileKey) obj; return new EqualsBuilder().append(mapKey, other.mapKey) .append(metaTileCoords, other.metaTileCoords).isEquals(); } public int getMetaFactor() { return 3; } public int getTileSize() { return 256; } public String toString() { return mapKey + "\nmtc:" + metaTileCoords.x + "," + metaTileCoords.y; } } /** * Gathers a tile from the cache, if available * * @param key * @param request * */ public synchronized RenderedImage getTile(MetaTileKey key, GetMapRequest request) { CacheElement ce = (CacheElement) tileCache.get(key); if (ce == null) { return null; } return getTile(key, request, ce.tiles); } /** * * @param key * @param request * @param tiles * */ public RenderedImage getTile(MetaTileKey key, GetMapRequest request, RenderedImage[] tiles) { Envelope bbox = request.getBbox(); if(CRS.getAxisOrder(request.getCrs()) == AxisOrder.NORTH_EAST) { bbox = new Envelope(bbox.getMinY(), bbox.getMaxY(), bbox.getMinX(), bbox.getMaxX()); } Point tileCoord = getTileOffsetsInMeta(bbox, key.getMetaTileEnvelope()); return tiles[tileCoord.x + (tileCoord.y * key.getMetaFactor())]; } /** * Puts the specified tile array in the cache, and returns the tile the request was looking for * * @param key * @param request * @param tiles * */ public synchronized void storeTiles(MetaTileKey key, RenderedImage[] tiles) { tileCache.put(key, new CacheElement(tiles)); } class CacheElement { RenderedImage[] tiles; public CacheElement(RenderedImage[] tiles) { this.tiles = tiles; } } public void dataStoreChange(TransactionEvent event) throws WFSException { // if anything changes we just wipe out the cache. the mapkey // contains a string with part of the map request where the layer // name is included, but we would have to parse it and consider // also that the namespace may be missing in the getmap request tileCache.clear(); } @Override public void onReset() { // data might have changed in the meantime tileCache.clear(); } @Override public void onDispose() { tileCache.clear(); } public void beforeReload() { // nothing to do } @Override public void onReload() { tileCache.clear(); } }