]> gerrit.simantics Code Review - simantics/district.git/blob - org.simantics.district.maps/src/org/simantics/maps/sg/MapNode.java
Ensure ITileProviders return BufferedImages with compatible ColorModel
[simantics/district.git] / org.simantics.district.maps / src / org / simantics / maps / sg / MapNode.java
1 /*******************************************************************************
2  * Copyright (c) 2012 Association for Decentralized Information Management
3  * in Industry THTH ry.
4  * All rights reserved. This program and the accompanying materials
5  * are made available under the terms of the Eclipse Public License v1.0
6  * which accompanies this distribution, and is available at
7  * http://www.eclipse.org/legal/epl-v10.html
8  *
9  * Contributors:
10  *     VTT Technical Research Centre of Finland - initial API and implementation
11  *******************************************************************************/
12 package org.simantics.maps.sg;
13
14 import java.awt.AlphaComposite;
15 import java.awt.Color;
16 import java.awt.Composite;
17 import java.awt.Font;
18 import java.awt.Graphics2D;
19 import java.awt.GraphicsConfiguration;
20 import java.awt.Image;
21 import java.awt.Rectangle;
22 import java.awt.RenderingHints;
23 import java.awt.geom.AffineTransform;
24 import java.awt.geom.Point2D;
25 import java.awt.geom.Rectangle2D;
26 import java.awt.image.ImageObserver;
27 import java.awt.image.VolatileImage;
28 import java.util.ArrayList;
29 import java.util.HashMap;
30 import java.util.HashSet;
31 import java.util.Hashtable;
32 import java.util.List;
33 import java.util.Map;
34 import java.util.concurrent.Executors;
35 import java.util.concurrent.ScheduledExecutorService;
36 import java.util.concurrent.ScheduledFuture;
37 import java.util.concurrent.TimeUnit;
38
39 import org.simantics.maps.WebService;
40 import org.simantics.maps.osm.OSMTileProvider;
41 import org.simantics.maps.pojo.TileJobQueue;
42 import org.simantics.maps.prefs.MapsClientPreferences;
43 import org.simantics.maps.tile.IFilter;
44 import org.simantics.maps.tile.ITileJobQueue;
45 import org.simantics.maps.tile.ITileListener;
46 import org.simantics.maps.tile.ITileProvider;
47 import org.simantics.maps.tile.TileCache;
48 import org.simantics.maps.tile.TileKey;
49 import org.simantics.scenegraph.g2d.G2DNode;
50 import org.simantics.scenegraph.g2d.G2DRenderingHints;
51 import org.slf4j.Logger;
52 import org.slf4j.LoggerFactory;
53
54 /**
55  * @author Tuukka Lehtonen
56  */
57 public class MapNode extends G2DNode implements ITileListener  {
58
59     private static final long serialVersionUID = 2490944880914577411L;
60
61     private static final Logger LOGGER = LoggerFactory.getLogger(MapNode.class);
62
63     private final double MAP_SCALE          = 1;
64     private final int    MAX_TILE_LEVEL     = 19;
65     private final int    TILE_PIXEL_SIZE    = 256;
66     private final int    VIEWBOX_QUIET_TIME = 500;
67
68     protected Boolean enabled = true;
69
70     enum TileState {
71         NOT_LOADED,
72         LOADING,
73         LOADED,
74         NOT_AVAILABLE
75     }
76
77     class TileLevel extends HashSet<TileKey> {
78         private static final long serialVersionUID = 5743763238677223952L;
79     }
80
81     static class TileTraverser {
82         List<TileKey> tile = new ArrayList<TileKey>(4);
83         List<Integer> quadrant = new ArrayList<Integer>(4);
84
85         static int getTileQuadrant(TileKey k) {
86             int x = k.getX();
87             int y = k.getY();
88             if ((x & 1) == 0) {
89                 return ((y & 1) == 0) ? 1 : 2;
90             }
91             return ((y & 1) == 0) ? 0 : 3;
92         }
93
94         void clear() {
95             tile.clear();
96             quadrant.clear();
97         }
98
99         void add(TileKey tile) {
100             this.tile.add(tile);
101             this.quadrant.add(getTileQuadrant(tile));
102         }
103
104         int size() {
105             return tile.size();
106         }
107
108         TileKey getTile(int i) {
109             return tile.get(i);
110         }
111
112         int getQuadrant(int i) {
113             return quadrant.get(i);
114         }
115     }
116
117     public class ObjectHolder<T>
118     {
119         private T o = null;
120         public T get() {
121             return o;
122         }
123         public void set(T o) {
124             this.o = o;
125         }
126     }
127
128     public final class Pair<T1, T2> {
129         public final T1 first;
130         public final T2 second;
131         public Pair(T1 first, T2 second) {
132             this.first = first;
133             this.second = second;
134         }
135     }
136
137     private TileCache                      tileCache;
138
139     // For delaying the retrieval of map data until the viewport remains steady
140     private ScheduledFuture<?>             pendingTileTask;
141
142     private Map<Integer, TileLevel>        neededTiles          = new HashMap<Integer, TileLevel>();
143
144     private TileTraverser                  tileTraverser        = new TileTraverser();
145
146     private ITileJobQueue                  job                  = null;
147
148     private Map<TileKey, TileState>        tileStates           = new Hashtable<TileKey, TileState>();
149     private ObjectHolder<VolatileImage>    notLoadedImage       = new ObjectHolder<VolatileImage>();
150     private ObjectHolder<VolatileImage>    loadingImage         = new ObjectHolder<VolatileImage>();
151     private ObjectHolder<VolatileImage>    notAvailableImage    = new ObjectHolder<VolatileImage>();
152
153     private Font                           textFont             = new Font(Font.SANS_SERIF, Font.BOLD, 40);
154
155         private AffineTransform                oldTransform         = new AffineTransform();
156     private final ScheduledExecutorService scheduler            = Executors.newScheduledThreadPool(1);
157
158     private Composite                      normalComposite      = AlphaComposite.SrcOver.derive(1f);
159     private Composite                      parentDataComposite  = AlphaComposite.SrcOver.derive(0.75f);
160
161     private Rectangle2D                    helperRect           = new Rectangle2D.Double();
162
163     ImageObserver observer = new ImageObserver() {
164         @Override
165         public boolean imageUpdate(Image img, int infoflags, int x, int y, int width, int height) {
166 //            System.out.format("imageUpdate(%s, %d, %d, %d, %d, %d)\n", img.toString(), infoflags, x, y, width, height);
167             return true;
168         }
169     };
170
171     public void init() {
172         // Construct WebService from client properties
173         String url;
174         if (MapsClientPreferences.useBuiltinServer())
175             url = MapsClientPreferences.possibleBuiltinServerURL();
176         else 
177             url = MapsClientPreferences.tileserverURL();
178         if (!url.endsWith("/"))
179             url = url + "/";
180         
181         try {
182             ITileProvider provider = new OSMTileProvider(new WebService(url), TILE_PIXEL_SIZE);
183
184             // Try to load eclipse specific implementation of TileJobQueue, if it doesn't exist, fall back to pojo implementation 
185             try {
186                 Class<?> proxyClass = (Class<?>) Class.forName("org.simantics.maps.eclipse.TileJobQueue");
187                 job = (ITileJobQueue)proxyClass.newInstance();
188             } catch (ClassNotFoundException e1) {
189             } catch (InstantiationException e) {
190             } catch (IllegalAccessException e) {
191             }
192             // AppletProxyUtil should exist always..
193             if(job == null) {
194                 job = new TileJobQueue();
195             }
196
197             // Cache is also eclipse specific...
198             ITileProvider cachedProvider = null;
199             try {
200                 Class<?> proxyClass = (Class<?>) Class.forName("org.simantics.maps.eclipse.DiskCachingTileProvider");
201                 cachedProvider = (ITileProvider)proxyClass.getConstructor(ITileProvider.class, Boolean.class).newInstance(provider, false);
202             } catch (ClassNotFoundException e1) {
203             } catch (InstantiationException e) {
204             } catch (IllegalAccessException e) {
205             }
206
207             if(cachedProvider != null) {
208                 job.setTileProvider(cachedProvider);
209             } else {
210                 job.setTileProvider(provider);
211             }
212
213             this.tileCache = new TileCache(job);
214             this.tileCache.addTileListener(this);
215         } catch(Exception e ) {
216             LOGGER.error("Failed to initialize MapNode", e);
217         }
218     }
219
220     @SyncField("enabled")
221     public void setEnabled(Boolean enabled) {
222         this.enabled = enabled;
223     }
224
225     @Override
226     public void render(Graphics2D g2d) {
227         if (!enabled)
228             return;
229
230         AffineTransform ot = g2d.getTransform();
231         g2d.transform(transform);
232         
233         AffineTransform tr = g2d.getTransform();
234         
235 //        Graphics2D g = (Graphics2D)g2d.create();
236 //        AffineTransform tr = (AffineTransform)g.getTransform().clone();
237         
238         double scaleX = Math.abs(tr.getScaleX());
239         double scaleY = Math.abs(tr.getScaleY());
240         if (scaleX <= 0 || scaleY <= 0) {
241             // Make sure that we don't end up in an eternal loop below.
242             return;
243         }
244         double offsetX = -tr.getTranslateX();
245         double offsetY = -tr.getTranslateY();
246
247         g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
248
249         Rectangle2D sp = localToControl(new Rectangle2D.Double(0.0, 0.0, 1.0, 1.0));
250         Rectangle2D b = (Rectangle2D)((Rectangle)g2d.getRenderingHint(G2DRenderingHints.KEY_CONTROL_BOUNDS)).getBounds2D(); // getClipBounds is not accurate enough, use original clipbounds and scale here
251
252         Rectangle2D viewbox = new Rectangle2D.Double(offsetX/scaleX, offsetY/scaleY, b.getWidth()/sp.getWidth(), b.getHeight()/sp.getHeight()); //g.getClipBounds();
253         if(viewbox == null) return; // FIXME
254
255         double smallerViewboxDimension = viewbox.getWidth() < viewbox.getHeight() ? viewbox.getWidth() * MAP_SCALE : viewbox.getHeight() * MAP_SCALE;
256         int level = 0;
257         double tileSize = 360 * MAP_SCALE*2;
258         while (level < MAX_TILE_LEVEL) {
259             double ratio = smallerViewboxDimension / tileSize;
260             if (ratio >= 0.85) {
261                 break;
262             }
263             tileSize *= 0.5;
264             level++;
265         }
266         /*
267          *  To convert y-coordinates to map coordinates in ruler, use:
268          *    double val = (y-offsetY)/scaleY;
269          *    val = Math.toDegrees(Math.atan(Math.sinh(Math.toRadians(val))));
270          *    String str = formatValue(val);
271          */
272
273         double minx = Math.min(180, Math.max(viewbox.getMinX(), -180));
274         double maxx = Math.min(180, Math.max(viewbox.getMaxX(), -180));
275
276         double miny = Math.min(360, Math.max(viewbox.getMinY()+180, 0));
277         double maxy = Math.min(360, Math.max(viewbox.getMaxY()+180, 0));
278
279         g2d.setTransform(new AffineTransform());
280         g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
281
282         int levels = (1 << level);
283
284         // http://wiki.openstreetmap.org/wiki/Slippy_map_tilenames
285         int left = (int)Math.floor( (minx + 180) / 360 * (1<<level) );
286         int right = (int)Math.floor( (maxx + 180) / 360 * (1<<level) );
287         int top = (int)Math.floor(miny / 360 * (1<<level));//  (int)Math.floor( (1 - Math.log(Math.tan(Math.toRadians(miny)) + 1 / Math.cos(Math.toRadians(miny))) / Math.PI) / 2 * (1<<level) );
288         int bottom = (int)Math.floor(maxy / 360 * (1<<level));//  (int)Math.floor( (1 - Math.log(Math.tan(Math.toRadians(maxy)) + 1 / Math.cos(Math.toRadians(maxy))) / Math.PI) / 2 * (1<<level) );
289
290         double tsx = 360 / (double)levels; // Size of tile on zoom level
291         for(int tx = left; tx <= right; tx++) {
292             if(tx < 0 || tx >= levels) continue;
293             for(int ty = top; ty <= bottom; ty++) {
294                 if(ty < 0 || ty >= levels) continue;
295                 TileKey tile = new TileKey(level, tx, ty);
296                 double y = (double)ty - (double)levels/2; // In level 0 we have only one tile
297                 paintTile(tileCache, g2d, tr, tile, tx*tsx-180, y*tsx, tsx);
298             }
299         }
300 //        g.dispose();
301
302         // Check if transform has changed
303         transformChanged(tr, level);
304         
305         g2d.setTransform(ot);
306     }
307
308     @Override
309     public Rectangle2D getBoundsInLocal() {
310         return null;
311     }
312
313     private void paintTile(TileCache cache, Graphics2D g, AffineTransform userToScreenTransform, TileKey tile, double tx, double ty, double tileSize) {
314         GraphicsConfiguration gc = g.getDeviceConfiguration();
315
316         Image img = null;
317         boolean usingParentData = false;
318         helperRect.setFrame(0, 0, TILE_PIXEL_SIZE, TILE_PIXEL_SIZE);
319
320         TileState state = tileStates.get(tile);
321         if (state == null)
322             state = TileState.NOT_LOADED;
323
324         switch (state) {
325             case NOT_LOADED:
326             case LOADED:
327             {
328                 // The same logic applies both when the data has not been loaded
329                 // or the data has been loaded previously, but may have been
330                 // flushed from the cache.
331                 img = cache.peek(tile);
332                 if (img == null) {
333                     // Either the data has never existed before or the cache has
334                     // flushed this tile. Mark it as needed and try to use
335                     // parent image instead.
336                     needTile(tile);
337
338                     Pair<TileKey, Image> p = findFirstAvailableParentTile(cache, tile, tileTraverser);
339                     if (p != null) {
340                         img = p.second;
341                         helperRect.setFrame(0, 0, img.getWidth(observer), img.getHeight(observer));
342                         traverseRectangle(tileTraverser, helperRect);
343                         usingParentData = true;
344                     } else {
345                         img = getNotLoadedImage(gc);
346                     }
347                 } else {
348                     helperRect.setFrame(0, 0, img.getWidth(observer), img.getHeight(observer));
349                 }
350                 break;
351             }
352             case LOADING: {
353                 Pair<TileKey, Image> p = findFirstAvailableParentTile(cache, tile, tileTraverser);
354                 if (p != null) {
355                     img = p.second;
356                     helperRect.setFrame(0, 0, img.getWidth(observer), img.getHeight(observer));
357                     traverseRectangle(tileTraverser, helperRect);
358                     usingParentData = true;
359                 } else {
360                     img = getLoadingImage(gc);
361                 }
362                 break;
363             }
364             case NOT_AVAILABLE: {
365                 img = getMissingImage(gc);
366                 break;
367             }
368             default:
369                 throw new Error("Invalid tile state: " + state);
370         }
371
372         // Calculate where these user space coordinates are in the screen
373         // space and given them to drawImage().
374         Point2D     helperPoint1  = new Point2D.Double(tx, ty);
375         Point2D     helperPoint2  = new Point2D.Double(tx + tileSize, ty + tileSize);
376         userToScreenTransform.transform(helperPoint1, helperPoint1);
377         userToScreenTransform.transform(helperPoint2, helperPoint2);
378
379         int dx1 = (int) Math.round(helperPoint1.getX());
380         int dy1 = (int) Math.round(helperPoint1.getY());
381         int dx2 = (int) Math.round(helperPoint2.getX());
382         int dy2 = (int) Math.round(helperPoint2.getY());
383
384         int sx1 = (int) Math.round(helperRect.getMinX());
385         int sy1 = (int) Math.round(helperRect.getMinY());
386         int sx2 = (int) Math.round(helperRect.getMaxX());
387         int sy2 = (int) Math.round(helperRect.getMaxY());
388
389         if (usingParentData) {
390             g.setComposite(parentDataComposite);
391         } else {
392             g.setComposite(normalComposite);
393         }
394         g.drawImage(img, dx1, dy1, dx2, dy2, sx1, sy1, sx2, sy2, observer);
395     }
396
397     private void transformChanged(AffineTransform newTransform, final int level) {
398         // Cancel data fetching if the viewport changes.
399         AffineTransform ato = (AffineTransform) oldTransform;
400         AffineTransform atn = (AffineTransform) newTransform;
401
402         double s1 = -1;
403         if (ato != null)
404             s1 = ato.getScaleX();
405         double s2 = atn.getScaleX();
406         double ds = s2 - s1;
407         if (Math.abs(ds) > 1e-6) {
408             if (pendingTileTask != null) {
409                 pendingTileTask.cancel(false);
410                 pendingTileTask = null;
411             }
412             Runnable r = new Runnable() {
413                 @Override
414                 public void run() {
415                     // There was a quiet period of [delay] milliseconds.
416                     // Now its time to start fetching the map data that has
417                     // piled up into the queue.
418                     loadNeededTiles(level);
419                 }
420             };
421             pendingTileTask = scheduler.schedule(r, VIEWBOX_QUIET_TIME, TimeUnit.MILLISECONDS);
422             oldTransform = (AffineTransform)newTransform.clone();
423         } else {
424             loadNeededTiles(level);
425         }
426     }
427
428     private void loadNeededTiles(final int level) {
429         TileCache cache = this.tileCache;
430
431         // Remove all tile queries from the job queue that do not match the
432         // requested tile level. This helps speed up loading of the right tiles
433         // that should be shown to the user ASAP instead of spending time
434         // loading invisible tiles.
435         cache.filterJobQueue(new IFilter<TileKey>() {
436             @Override
437             public boolean select(TileKey tile) {
438                 boolean result = (tile.getLevel() == level);
439                 //if (!result)
440                 //    System.out.println("FILTERED TILE: " + tile);
441                 return result;
442             }
443         });
444
445         // Schedule retrieval of each tile on the current level.
446         TileLevel l = null;
447         synchronized (neededTiles) {
448             l = neededTiles.get(level);
449             neededTiles.clear();
450         }
451         if (l != null && !l.isEmpty()) {
452             for (TileKey k : l) {
453                 tileStates.put(k, TileState.LOADING);
454                 tileCache.get(k);
455             }
456         }
457     }
458
459     void needTile(TileKey t) {
460         synchronized (neededTiles) {
461             int level = t.getLevel();
462             TileLevel l = neededTiles.get(level);
463             if (l == null) {
464                 l = new TileLevel();
465                 neededTiles.put(level, l);
466             }
467             l.add(t);
468         }
469     }
470
471     private Pair<TileKey, Image> findFirstAvailableParentTile(TileCache cache, TileKey tile, TileTraverser traverser) {
472         traverser.clear();
473         traverser.add(tile);
474
475         TileKey parent = getParentTile(tile);
476         while (parent != null) {
477             Image img = cache.peek(parent);
478             if (img != null)
479                 return new Pair<TileKey, Image>(parent, img);
480             traverser.add(parent);
481             parent = getParentTile(parent);
482         }
483         return null;
484     }
485
486     private TileKey getParentTile(TileKey k) {
487         if (k.getLevel() == 0)
488             return null;
489         return new TileKey(k.getLevel() - 1, k.getX() / 2, k.getY() / 2);
490     }
491
492     private void traverseRectangle(TileTraverser traverser, Rectangle2D rect) {
493         for (int i = traverser.size() - 1; i >= 0; --i) {
494             traverseToQuadrant(rect, traverser.getQuadrant(i));
495         }
496     }
497
498     private Rectangle2D traverseToQuadrant(Rectangle2D r, int quadrant) {
499         boolean left = quadrant == 1 || quadrant == 2;
500         boolean top = quadrant == 1 || quadrant == 0;
501         r.setFrame(
502                 left ? r.getMinX() : r.getCenterX(),
503                 top ? r.getMinY() : r.getCenterY(),
504                 r.getWidth() / 2.0,
505                 r.getHeight() / 2.0);
506         return r;
507     }
508
509     private synchronized VolatileImage getNotLoadedImage(GraphicsConfiguration gc) {
510         return validateImageWithCenteredText(gc, notLoadedImage, TILE_PIXEL_SIZE, "NOT LOADED", Color.LIGHT_GRAY, Color.BLACK);
511     }
512
513     private synchronized VolatileImage getLoadingImage(GraphicsConfiguration gc) {
514         return validateImageWithCenteredText(gc, loadingImage, TILE_PIXEL_SIZE, "LOADING", Color.BLUE, Color.BLACK);
515     }
516
517     private synchronized VolatileImage getMissingImage(GraphicsConfiguration gc) {
518         return validateImageWithCenteredText(gc, notAvailableImage, TILE_PIXEL_SIZE, "NOT AVAILABLE", Color.RED, Color.DARK_GRAY);
519     }
520
521     private void drawImageWithCenteredText(VolatileImage image, String str, Font font, Color color, Color bgColor) {
522         Graphics2D target = image.createGraphics();
523         Rectangle2D bb = target.getFontMetrics(font).getStringBounds(str, target);
524
525         target.setBackground(new Color(255,255,255,0));
526         int w = image.getWidth();
527         int h = image.getHeight();
528
529         target.setColor(Color.BLACK);
530         target.fillRect(0, 0, w, h);
531         target.setColor(bgColor);
532         target.fillRect(2, 2, w - 3, h - 3);
533         target.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
534         target.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
535         target.setRenderingHint(RenderingHints.KEY_ALPHA_INTERPOLATION, RenderingHints.VALUE_ALPHA_INTERPOLATION_QUALITY);
536         target.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
537         target.setColor(color);
538         target.setFont(font);
539         target.drawString(str, (float) (((double)w - bb.getWidth()) / 2), (float) (((double)h + bb.getHeight()) / 2));
540         target.dispose();
541     }
542
543     private VolatileImage validateImageWithCenteredText(GraphicsConfiguration gc, ObjectHolder<VolatileImage> container, int size, String text, Color textColor, Color bgColor) {
544         VolatileImage image = container.get();
545         if (image != null) {
546             int validateResult = image.validate(gc);
547             if (validateResult == VolatileImage.IMAGE_INCOMPATIBLE) {
548                 image = null;
549             } else if (validateResult == VolatileImage.IMAGE_OK) {
550                 return image;
551             } else if (validateResult == VolatileImage.IMAGE_RESTORED) {
552                 drawImageWithCenteredText(image, text, textFont, textColor, bgColor);
553             }
554         }
555         if (image == null) {
556             image = gc.createCompatibleVolatileImage(size, size);
557             container.set(image);
558             drawImageWithCenteredText(image, text, textFont, textColor, bgColor);
559         }
560         return image;
561     }
562
563     @Override
564     public void tileCanceled(TileKey key) {
565         tileStates.put(key, TileState.NOT_LOADED);
566     }
567
568     @Override
569     public void tileFailed(TileKey key, Throwable e) {
570         tileStates.put(key, TileState.NOT_AVAILABLE);
571         repaint();
572     }
573
574     @Override
575     public void tileUpdated(TileKey key, Image image) {
576         tileStates.put(key, TileState.LOADED);
577         repaint();
578     }
579
580 }