package cgeo.geocaching.connector.gc; import cgeo.geocaching.location.Geopoint; import cgeo.geocaching.location.Viewport; import cgeo.geocaching.models.ICoordinates; import cgeo.geocaching.network.Network; import cgeo.geocaching.network.Parameters; import cgeo.geocaching.utils.AndroidRxUtils; import cgeo.geocaching.utils.LeastRecentlyUsedSet; import android.graphics.Bitmap; import android.graphics.BitmapFactory; import android.support.annotation.NonNull; import java.io.IOException; import java.util.ArrayList; import java.util.HashSet; import java.util.Locale; import java.util.Set; import io.reactivex.Single; import io.reactivex.functions.Function; import okhttp3.Response; /** * All about tiles. * * @see <a href="http://msdn.microsoft.com/en-us/library/bb259689.aspx">MSDN</a> * @see <a * href="http://svn.openstreetmap.org/applications/viewer/jmapviewer/src/org/openstreetmap/gui/jmapviewer/OsmMercator.java">OSM</a> */ public class Tile { static final int TILE_SIZE = 256; static final int ZOOMLEVEL_MAX = 18; public static final int ZOOMLEVEL_MIN = 0; public static final int ZOOMLEVEL_MIN_PERSONALIZED = 12; private static final int[] NUMBER_OF_TILES = new int[ZOOMLEVEL_MAX - ZOOMLEVEL_MIN + 1]; private static final int[] NUMBER_OF_PIXELS = new int[ZOOMLEVEL_MAX - ZOOMLEVEL_MIN + 1]; static { for (int z = ZOOMLEVEL_MIN; z <= ZOOMLEVEL_MAX; z++) { NUMBER_OF_TILES[z] = 1 << z; NUMBER_OF_PIXELS[z] = TILE_SIZE * 1 << z; } } public static final TileCache cache = new TileCache(); private final int tileX; private final int tileY; private final int zoomLevel; private final Viewport viewPort; public Tile(final Geopoint origin, final int zoomlevel) { this(calcX(origin, clippedZoomlevel(zoomlevel)), calcY(origin, clippedZoomlevel(zoomlevel)), clippedZoomlevel(zoomlevel)); } private Tile(final int tileX, final int tileY, final int zoomlevel) { this.zoomLevel = clippedZoomlevel(zoomlevel); this.tileX = tileX; this.tileY = tileY; viewPort = new Viewport(getCoord(new UTFGridPosition(0, 0)), getCoord(new UTFGridPosition(63, 63))); } public int getZoomLevel() { return zoomLevel; } private static int clippedZoomlevel(final int zoomlevel) { return Math.max(Math.min(zoomlevel, ZOOMLEVEL_MAX), ZOOMLEVEL_MIN); } /** * Calculate the tile for a Geopoint based on the Spherical Mercator. * * @see <a * href="http://developers.cloudmade.com/projects/tiles/examples/convert-coordinates-to-tile-numbers">Cloudmade</a> */ private static int calcX(final Geopoint origin, final int zoomlevel) { // The cut of the fractional part instead of rounding to the nearest integer is intentional and part of the algorithm return (int) ((origin.getLongitude() + 180.0) / 360.0 * NUMBER_OF_TILES[zoomlevel]); } /** * Calculate the tile for a Geopoint based on the Spherical Mercator. * */ private static int calcY(final Geopoint origin, final int zoomlevel) { // Optimization from Bing final double sinLatRad = Math.sin(Math.toRadians(origin.getLatitude())); // The cut of the fractional part instead of rounding to the nearest integer is intentional and part of the algorithm return (int) ((0.5 - Math.log((1 + sinLatRad) / (1 - sinLatRad)) / (4 * Math.PI)) * NUMBER_OF_TILES[zoomlevel]); } public int getX() { return tileX; } public int getY() { return tileY; } /** * Calculate latitude/longitude for a given x/y position in this tile. * * @see <a * href="http://developers.cloudmade.com/projects/tiles/examples/convert-coordinates-to-tile-numbers">Cloudmade</a> */ @NonNull Geopoint getCoord(final UTFGridPosition pos) { final double pixX = tileX * TILE_SIZE + pos.x * 4; final double pixY = tileY * TILE_SIZE + pos.y * 4; final double lonDeg = ((360.0 * pixX) / NUMBER_OF_PIXELS[this.zoomLevel]) - 180.0; final double latRad = Math.atan(Math.sinh(Math.PI * (1 - 2 * pixY / NUMBER_OF_PIXELS[this.zoomLevel]))); return new Geopoint(Math.toDegrees(latRad), lonDeg); } @Override public String toString() { return String.format(Locale.US, "(%d/%d), zoom=%d", tileX, tileY, zoomLevel); } /** * Calculates the maximum possible zoom level where the supplied points * are covered by at least by the supplied number of * adjacent tiles on the east/west axis. * This criterion can be exactly met for even numbers of tiles * while it may result in one more tile as requested for odd numbers * of tiles. * * The order of the points (left/right) is irrelevant. * * @param left * First point * @param right * Second point */ static int calcZoomLon(final Geopoint left, final Geopoint right, final int numberOfTiles) { int zoom = (int) Math.floor( Math.log(360.0 * numberOfTiles / (2.0 * Math.abs(left.getLongitude() - right.getLongitude()))) / Math.log(2) ); final Tile tileLeft = new Tile(left, zoom); final Tile tileRight = new Tile(right, zoom); if (Math.abs(tileLeft.tileX - tileRight.tileX) < (numberOfTiles - 1)) { zoom += 1; } return Math.min(zoom, ZOOMLEVEL_MAX); } /** * Calculates the maximum possible zoom level where the supplied points * are covered by at least by the supplied number of * adjacent tiles on the north/south axis. * This criterion can be exactly met for even numbers of tiles * while it may result in one more tile as requested for odd numbers * of tiles. * * The order of the points (bottom/top) is irrelevant. * * @param bottom * First point * @param top * Second point */ static int calcZoomLat(final Geopoint bottom, final Geopoint top, final int numberOfTiles) { int zoom = (int) Math.ceil( Math.log(2.0 * Math.PI * numberOfTiles / ( Math.abs( asinh(tanGrad(bottom.getLatitude())) - asinh(tanGrad(top.getLatitude())) ) * 2.0) ) / Math.log(2) ); final Tile tileBottom = new Tile(bottom, zoom); final Tile tileTop = new Tile(top, zoom); if (Math.abs(tileBottom.tileY - tileTop.tileY) > (numberOfTiles - 1)) { zoom -= 1; } return Math.min(zoom, ZOOMLEVEL_MAX); } private static double tanGrad(final double angleGrad) { return Math.tan(angleGrad / 180.0 * Math.PI); } /** * Calculates the inverted hyperbolic sine * (after Bronstein, Semendjajew: Taschenbuch der Mathematik) * */ private static double asinh(final double x) { return Math.log(x + Math.sqrt(x * x + 1.0)); } @Override public boolean equals(final Object o) { if (this == o) { return true; } if (!(o instanceof Tile)) { return false; } final Tile other = (Tile) o; return this.tileX == other.tileX && this.tileY == other.tileY && this.zoomLevel == other.zoomLevel; } @Override public int hashCode() { return toString().hashCode(); } /** Request JSON informations for a tile. Return as soon as the request has been made, before the answer has been * read. * * @return A single with one element, or an IOException */ static Single<String> requestMapInfo(final String url, final Parameters params, final String referer) { try { final Response response = Network.getRequest(url, params, new Parameters("Referer", referer)).blockingGet(); return Single.just(response).flatMap(Network.getResponseData); } catch (final Exception e) { return Single.error(e); } } /** Request .png image for a tile. Return as soon as the request has been made, before the answer has been * read and processed. * * @return A single with one element, or an IOException */ static Single<Bitmap> requestMapTile(final Parameters params) { try { final Response response = Network.getRequest(GCConstants.URL_MAP_TILE, params, new Parameters("Referer", GCConstants.URL_LIVE_MAP)).blockingGet(); return Single.just(response) .flatMap(new Function<Response, Single<Bitmap>>() { @Override public Single<Bitmap> apply(final Response response) { try { if (response.isSuccessful()) { final Bitmap bitmap = BitmapFactory.decodeStream(response.body().byteStream()); if (bitmap != null) { return Single.just(bitmap); } } return Single.error(new IOException("could not decode bitmap")); } finally { response.close(); } } }).subscribeOn(AndroidRxUtils.computationScheduler); } catch (final Exception e) { return Single.error(e); } } public boolean containsPoint(@NonNull final ICoordinates point) { return viewPort.contains(point); } public Viewport getViewport() { return viewPort; } /** * Calculate needed tiles for the given viewport to cover it with * max 2x2 tiles * */ protected static Set<Tile> getTilesForViewport(final Viewport viewport) { return getTilesForViewport(viewport, 2, ZOOMLEVEL_MIN); } /** * Calculate needed tiles for the given viewport. * You can define the minimum number of tiles on the longer axis * and/or the minimum zoom level. * */ protected static Set<Tile> getTilesForViewport(final Viewport viewport, final int tilesOnAxis, final int minZoom) { final Set<Tile> tiles = new HashSet<>(); final int zoom = Math.max( Math.min(calcZoomLon(viewport.bottomLeft, viewport.topRight, tilesOnAxis), calcZoomLat(viewport.bottomLeft, viewport.topRight, tilesOnAxis)), minZoom); final Tile tileBottomLeft = new Tile(viewport.bottomLeft, zoom); final Tile tileTopRight = new Tile(viewport.topRight, zoom); final int xLow = Math.min(tileBottomLeft.getX(), tileTopRight.getX()); final int xHigh = Math.max(tileBottomLeft.getX(), tileTopRight.getX()); final int yLow = Math.min(tileBottomLeft.getY(), tileTopRight.getY()); final int yHigh = Math.max(tileBottomLeft.getY(), tileTopRight.getY()); for (int xNum = xLow; xNum <= xHigh; xNum++) { for (int yNum = yLow; yNum <= yHigh; yNum++) { tiles.add(new Tile(xNum, yNum, zoom)); } } return tiles; } public static class TileCache extends LeastRecentlyUsedSet<Tile> { public TileCache() { super(64); } public void removeFromTileCache(@NonNull final ICoordinates point) { for (final Tile tile : new ArrayList<>(this)) { if (tile.containsPoint(point)) { remove(tile); } } } } }