001// License: GPL. For details, see Readme.txt file. 002package org.openstreetmap.gui.jmapviewer; 003 004import java.awt.Graphics; 005import java.awt.Graphics2D; 006import java.awt.geom.AffineTransform; 007import java.awt.image.BufferedImage; 008import java.io.IOException; 009import java.io.InputStream; 010import java.util.HashMap; 011import java.util.Map; 012import java.util.Objects; 013import java.util.concurrent.Callable; 014 015import javax.imageio.ImageIO; 016 017import org.openstreetmap.gui.jmapviewer.interfaces.TileCache; 018import org.openstreetmap.gui.jmapviewer.interfaces.TileSource; 019 020/** 021 * Holds one map tile. Additionally the code for loading the tile image and 022 * painting it is also included in this class. 023 * 024 * @author Jan Peter Stotz 025 */ 026public class Tile { 027 028 /** 029 * Hourglass image that is displayed until a map tile has been loaded, except for overlay sources 030 */ 031 public static final BufferedImage LOADING_IMAGE = loadImage("images/hourglass.png"); 032 033 /** 034 * Red cross image that is displayed after a loading error, except for overlay sources 035 */ 036 public static final BufferedImage ERROR_IMAGE = loadImage("images/error.png"); 037 038 protected TileSource source; 039 protected int xtile; 040 protected int ytile; 041 protected int zoom; 042 protected BufferedImage image; 043 protected String key; 044 protected volatile boolean loaded; // field accessed by multiple threads without any monitors, needs to be volatile 045 protected volatile boolean loading; 046 protected volatile boolean error; 047 protected String error_message; 048 049 /** TileLoader-specific tile metadata */ 050 protected Map<String, String> metadata; 051 052 /** 053 * Creates a tile with empty image. 054 * 055 * @param source Tile source 056 * @param xtile X coordinate 057 * @param ytile Y coordinate 058 * @param zoom Zoom level 059 */ 060 public Tile(TileSource source, int xtile, int ytile, int zoom) { 061 this(source, xtile, ytile, zoom, LOADING_IMAGE); 062 } 063 064 /** 065 * Creates a tile with specified image. 066 * 067 * @param source Tile source 068 * @param xtile X coordinate 069 * @param ytile Y coordinate 070 * @param zoom Zoom level 071 * @param image Image content 072 */ 073 public Tile(TileSource source, int xtile, int ytile, int zoom, BufferedImage image) { 074 this.source = source; 075 this.xtile = xtile; 076 this.ytile = ytile; 077 this.zoom = zoom; 078 this.image = image; 079 this.key = getTileKey(source, xtile, ytile, zoom); 080 } 081 082 private static BufferedImage loadImage(String path) { 083 try { 084 return FeatureAdapter.readImage(JMapViewer.class.getResource(path)); 085 } catch (IOException | IllegalArgumentException ex) { 086 ex.printStackTrace(); 087 return null; 088 } 089 } 090 091 private static class CachedCallable<V> implements Callable<V> { 092 private V result; 093 private Callable<V> callable; 094 095 /** 096 * Wraps callable so it is evaluated only once 097 * @param callable to cache 098 */ 099 CachedCallable(Callable<V> callable) { 100 this.callable = callable; 101 } 102 103 @Override 104 public synchronized V call() { 105 try { 106 if (result == null) { 107 result = callable.call(); 108 } 109 return result; 110 } catch (Exception e) { 111 // this should not happen here 112 throw new RuntimeException(e); 113 } 114 } 115 } 116 117 /** 118 * Tries to get tiles of a lower or higher zoom level (one or two level 119 * difference) from cache and use it as a placeholder until the tile has been loaded. 120 * @param cache Tile cache 121 */ 122 public void loadPlaceholderFromCache(TileCache cache) { 123 /* 124 * use LazyTask as creation of BufferedImage is very expensive 125 * this way we can avoid object creation until we're sure it's needed 126 */ 127 final CachedCallable<BufferedImage> tmpImage = new CachedCallable<>(new Callable<BufferedImage>() { 128 @Override 129 public BufferedImage call() throws Exception { 130 return new BufferedImage(source.getTileSize(), source.getTileSize(), BufferedImage.TYPE_INT_ARGB); 131 } 132 }); 133 134 for (int zoomDiff = 1; zoomDiff < 5; zoomDiff++) { 135 // first we check if there are already the 2^x tiles 136 // of a higher detail level 137 int zoomHigh = zoom + zoomDiff; 138 if (zoomDiff < 3 && zoomHigh <= JMapViewer.MAX_ZOOM) { 139 int factor = 1 << zoomDiff; 140 int xtileHigh = xtile << zoomDiff; 141 int ytileHigh = ytile << zoomDiff; 142 final double scale = 1.0 / factor; 143 144 /* 145 * use LazyTask for graphics to avoid evaluation of tmpImage, until we have 146 * something to draw 147 */ 148 CachedCallable<Graphics2D> graphics = new CachedCallable<>(new Callable<Graphics2D>() { 149 @Override 150 public Graphics2D call() throws Exception { 151 Graphics2D g = (Graphics2D) tmpImage.call().getGraphics(); 152 g.setTransform(AffineTransform.getScaleInstance(scale, scale)); 153 return g; 154 } 155 }); 156 157 int paintedTileCount = 0; 158 for (int x = 0; x < factor; x++) { 159 for (int y = 0; y < factor; y++) { 160 Tile tile = cache.getTile(source, xtileHigh + x, ytileHigh + y, zoomHigh); 161 if (tile != null && tile.isLoaded()) { 162 paintedTileCount++; 163 tile.paint(graphics.call(), x * source.getTileSize(), y * source.getTileSize()); 164 } 165 } 166 } 167 if (paintedTileCount == factor * factor) { 168 image = tmpImage.call(); 169 return; 170 } 171 } 172 173 int zoomLow = zoom - zoomDiff; 174 if (zoomLow >= JMapViewer.MIN_ZOOM) { 175 int xtileLow = xtile >> zoomDiff; 176 int ytileLow = ytile >> zoomDiff; 177 final int factor = 1 << zoomDiff; 178 final double scale = factor; 179 CachedCallable<Graphics2D> graphics = new CachedCallable<>(new Callable<Graphics2D>() { 180 @Override 181 public Graphics2D call() throws Exception { 182 Graphics2D g = (Graphics2D) tmpImage.call().getGraphics(); 183 AffineTransform at = new AffineTransform(); 184 int translateX = (xtile % factor) * source.getTileSize(); 185 int translateY = (ytile % factor) * source.getTileSize(); 186 at.setTransform(scale, 0, 0, scale, -translateX, -translateY); 187 g.setTransform(at); 188 return g; 189 } 190 191 }); 192 193 Tile tile = cache.getTile(source, xtileLow, ytileLow, zoomLow); 194 if (tile != null && tile.isLoaded()) { 195 tile.paint(graphics.call(), 0, 0); 196 image = tmpImage.call(); 197 return; 198 } 199 } 200 } 201 } 202 203 public TileSource getSource() { 204 return source; 205 } 206 207 /** 208 * Returns the X coordinate. 209 * @return tile number on the x axis of this tile 210 */ 211 public int getXtile() { 212 return xtile; 213 } 214 215 /** 216 * Returns the Y coordinate. 217 * @return tile number on the y axis of this tile 218 */ 219 public int getYtile() { 220 return ytile; 221 } 222 223 /** 224 * Returns the zoom level. 225 * @return zoom level of this tile 226 */ 227 public int getZoom() { 228 return zoom; 229 } 230 231 /** 232 * @return tile indexes of the top left corner as TileXY object 233 */ 234 public TileXY getTileXY() { 235 return new TileXY(xtile, ytile); 236 } 237 238 public BufferedImage getImage() { 239 return image; 240 } 241 242 public void setImage(BufferedImage image) { 243 this.image = image; 244 } 245 246 public void loadImage(InputStream input) throws IOException { 247 setImage(ImageIO.read(input)); 248 } 249 250 /** 251 * @return key that identifies a tile 252 */ 253 public String getKey() { 254 return key; 255 } 256 257 public boolean isLoaded() { 258 return loaded; 259 } 260 261 public boolean isLoading() { 262 return loading; 263 } 264 265 public void setLoaded(boolean loaded) { 266 this.loaded = loaded; 267 } 268 269 public String getUrl() throws IOException { 270 return source.getTileUrl(zoom, xtile, ytile); 271 } 272 273 /** 274 * Paints the tile-image on the {@link Graphics} <code>g</code> at the 275 * position <code>x</code>/<code>y</code>. 276 * 277 * @param g the Graphics object 278 * @param x x-coordinate in <code>g</code> 279 * @param y y-coordinate in <code>g</code> 280 */ 281 public void paint(Graphics g, int x, int y) { 282 if (image == null) 283 return; 284 g.drawImage(image, x, y, null); 285 } 286 287 /** 288 * Paints the tile-image on the {@link Graphics} <code>g</code> at the 289 * position <code>x</code>/<code>y</code>. 290 * 291 * @param g the Graphics object 292 * @param x x-coordinate in <code>g</code> 293 * @param y y-coordinate in <code>g</code> 294 * @param width width that tile should have 295 * @param height height that tile should have 296 */ 297 public void paint(Graphics g, int x, int y, int width, int height) { 298 if (image == null) 299 return; 300 g.drawImage(image, x, y, width, height, null); 301 } 302 303 @Override 304 public String toString() { 305 StringBuilder sb = new StringBuilder(35).append("Tile ").append(key); 306 if (loading) { 307 sb.append(" [LOADING...]"); 308 } 309 if (loaded) { 310 sb.append(" [loaded]"); 311 } 312 if (error) { 313 sb.append(" [ERROR]"); 314 } 315 return sb.toString(); 316 } 317 318 /** 319 * Note that the hash code does not include the {@link #source}. 320 * Therefore a hash based collection can only contain tiles 321 * of one {@link #source}. 322 */ 323 @Override 324 public int hashCode() { 325 final int prime = 31; 326 int result = 1; 327 result = prime * result + xtile; 328 result = prime * result + ytile; 329 result = prime * result + zoom; 330 return result; 331 } 332 333 /** 334 * Compares this object with <code>obj</code> based on 335 * the fields {@link #xtile}, {@link #ytile} and 336 * {@link #zoom}. 337 * The {@link #source} field is ignored. 338 */ 339 @Override 340 public boolean equals(Object obj) { 341 if (this == obj) 342 return true; 343 if (obj == null || !(obj instanceof Tile)) 344 return false; 345 final Tile other = (Tile) obj; 346 return xtile == other.xtile 347 && ytile == other.ytile 348 && zoom == other.zoom 349 && Objects.equals(source, other.source); 350 } 351 352 public static String getTileKey(TileSource source, int xtile, int ytile, int zoom) { 353 return zoom + "/" + xtile + "/" + ytile + "@" + source.getName(); 354 } 355 356 public String getStatus() { 357 if (this.error) 358 return "error"; 359 if (this.loaded) 360 return "loaded"; 361 if (this.loading) 362 return "loading"; 363 return "new"; 364 } 365 366 public boolean hasError() { 367 return error; 368 } 369 370 public String getErrorMessage() { 371 return error_message; 372 } 373 374 public void setError(Exception e) { 375 setError(e.toString()); 376 } 377 378 public void setError(String message) { 379 error = true; 380 setImage(ERROR_IMAGE); 381 error_message = message; 382 } 383 384 /** 385 * Puts the given key/value pair to the metadata of the tile. 386 * If value is null, the (possibly existing) key/value pair is removed from 387 * the meta data. 388 * 389 * @param key Key 390 * @param value Value 391 */ 392 public void putValue(String key, String value) { 393 if (value == null || value.isEmpty()) { 394 if (metadata != null) { 395 metadata.remove(key); 396 } 397 return; 398 } 399 if (metadata == null) { 400 metadata = new HashMap<>(); 401 } 402 metadata.put(key, value); 403 } 404 405 /** 406 * returns the metadata of the Tile 407 * 408 * @param key metadata key that should be returned 409 * @return null if no such metadata exists, or the value of the metadata 410 */ 411 public String getValue(String key) { 412 if (metadata == null) return null; 413 return metadata.get(key); 414 } 415 416 /** 417 * 418 * @return metadata of the tile 419 */ 420 public Map<String, String> getMetadata() { 421 if (metadata == null) { 422 metadata = new HashMap<>(); 423 } 424 return metadata; 425 } 426 427 /** 428 * indicate that loading process for this tile has started 429 */ 430 public void initLoading() { 431 error = false; 432 loading = true; 433 } 434 435 /** 436 * indicate that loading process for this tile has ended 437 */ 438 public void finishLoading() { 439 loading = false; 440 loaded = true; 441 } 442 443 /** 444 * 445 * @return TileSource from which this tile comes 446 */ 447 public TileSource getTileSource() { 448 return source; 449 } 450 451 /** 452 * indicate that loading process for this tile has been canceled 453 */ 454 public void loadingCanceled() { 455 loading = false; 456 loaded = false; 457 } 458}