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}