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