1 /*******************************************************************************
2 * Copyright (c) 2012 Association for Decentralized Information Management
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
10 * VTT Technical Research Centre of Finland - initial API and implementation
11 *******************************************************************************/
12 package org.simantics.maps.sg;
14 import java.awt.AlphaComposite;
15 import java.awt.Color;
16 import java.awt.Composite;
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.net.MalformedURLException;
29 import java.net.URISyntaxException;
30 import java.util.ArrayList;
31 import java.util.HashMap;
32 import java.util.HashSet;
33 import java.util.Hashtable;
34 import java.util.List;
36 import java.util.concurrent.Executors;
37 import java.util.concurrent.ScheduledExecutorService;
38 import java.util.concurrent.ScheduledFuture;
39 import java.util.concurrent.TimeUnit;
41 import org.eclipse.core.runtime.preferences.IEclipsePreferences.IPreferenceChangeListener;
42 import org.simantics.maps.MapScalingTransform;
43 import org.simantics.maps.WebService;
44 import org.simantics.maps.osm.OSMTileProvider;
45 import org.simantics.maps.pojo.TileJobQueue;
46 import org.simantics.maps.prefs.MapsClientPreferences;
47 import org.simantics.maps.tile.IFilter;
48 import org.simantics.maps.tile.ITileJobQueue;
49 import org.simantics.maps.tile.ITileListener;
50 import org.simantics.maps.tile.ITileProvider;
51 import org.simantics.maps.tile.TileCache;
52 import org.simantics.maps.tile.TileKey;
53 import org.simantics.scenegraph.g2d.G2DNode;
54 import org.simantics.scenegraph.g2d.G2DRenderingHints;
55 import org.slf4j.Logger;
56 import org.slf4j.LoggerFactory;
59 * @author Tuukka Lehtonen
61 public class MapNode extends G2DNode implements ITileListener {
63 private static final long serialVersionUID = 2490944880914577411L;
65 private static final Logger LOGGER = LoggerFactory.getLogger(MapNode.class);
67 //private final double MAP_SCALE = 1;
68 //private final int MAX_TILE_LEVEL = 19;
69 private final int TILE_PIXEL_SIZE = 256;
70 private final int VIEWBOX_QUIET_TIME = 500;
72 protected Boolean enabled = true;
73 protected Color backgroundColor;
82 class TileLevel extends HashSet<TileKey> {
83 private static final long serialVersionUID = 5743763238677223952L;
86 static class TileTraverser {
87 List<TileKey> tile = new ArrayList<TileKey>(4);
88 List<Integer> quadrant = new ArrayList<Integer>(4);
90 static int getTileQuadrant(TileKey k) {
94 return ((y & 1) == 0) ? 1 : 2;
96 return ((y & 1) == 0) ? 0 : 3;
104 void add(TileKey tile) {
106 this.quadrant.add(getTileQuadrant(tile));
113 TileKey getTile(int i) {
117 int getQuadrant(int i) {
118 return quadrant.get(i);
122 public class ObjectHolder<T>
128 public void set(T o) {
133 public final class Pair<T1, T2> {
134 public final T1 first;
135 public final T2 second;
136 public Pair(T1 first, T2 second) {
138 this.second = second;
142 private TileCache tileCache;
144 // For delaying the retrieval of map data until the viewport remains steady
145 private ScheduledFuture<?> pendingTileTask;
147 private Map<Integer, TileLevel> neededTiles = new HashMap<Integer, TileLevel>();
149 private TileTraverser tileTraverser = new TileTraverser();
151 private ITileJobQueue job = null;
153 private Map<TileKey, TileState> tileStates = new Hashtable<TileKey, TileState>();
154 private ObjectHolder<VolatileImage> notLoadedImage = new ObjectHolder<VolatileImage>();
155 private ObjectHolder<VolatileImage> loadingImage = new ObjectHolder<VolatileImage>();
156 private ObjectHolder<VolatileImage> notAvailableImage = new ObjectHolder<VolatileImage>();
158 private Font textFont = new Font(Font.SANS_SERIF, Font.BOLD, 40);
160 private AffineTransform oldTransform = new AffineTransform();
161 private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1);
163 private Composite normalComposite = AlphaComposite.SrcOver.derive(1f);
164 private Composite parentDataComposite = AlphaComposite.SrcOver.derive(0.75f);
166 private Rectangle2D helperRect = new Rectangle2D.Double();
168 ImageObserver observer = new ImageObserver() {
170 public boolean imageUpdate(Image img, int infoflags, int x, int y, int width, int height) {
171 // System.out.format("imageUpdate(%s, %d, %d, %d, %d, %d)\n", img.toString(), infoflags, x, y, width, height);
176 private IPreferenceChangeListener listener;
180 OSMTileProvider provider = new OSMTileProvider(new WebService(computeUrl()), TILE_PIXEL_SIZE);
182 listener = event -> {
183 // if tiles or style change we want to flush the tile cache
184 if (MapsClientPreferences.P_CURRENT_MBTILES.equals(event.getKey()) || MapsClientPreferences.P_CURRENT_TM2STYLE.equals(event.getKey())) {
185 if (tileCache != null) {
189 provider.setWebService(new WebService(computeUrl()));
190 } catch (MalformedURLException | URISyntaxException e) {
191 LOGGER.error("Unable to update WebService with new url", e);
195 MapsClientPreferences.addPreferenceChangeListenerMapsServer(listener);
197 // Try to load eclipse specific implementation of TileJobQueue, if it doesn't exist, fall back to pojo implementation
199 Class<?> proxyClass = (Class<?>) Class.forName("org.simantics.maps.eclipse.TileJobQueue");
200 job = (ITileJobQueue)proxyClass.newInstance();
201 } catch (ClassNotFoundException e1) {
202 } catch (InstantiationException e) {
203 } catch (IllegalAccessException e) {
205 // AppletProxyUtil should exist always..
207 job = new TileJobQueue();
210 // Cache is also eclipse specific...
211 ITileProvider cachedProvider = null;
213 Class<?> proxyClass = (Class<?>) Class.forName("org.simantics.maps.eclipse.DiskCachingTileProvider");
214 cachedProvider = (ITileProvider)proxyClass.getConstructor(ITileProvider.class, Boolean.class).newInstance(provider, false);
215 } catch (ClassNotFoundException e1) {
216 } catch (InstantiationException e) {
217 } catch (IllegalAccessException e) {
220 if(cachedProvider != null) {
221 job.setTileProvider(cachedProvider);
223 job.setTileProvider(provider);
226 this.tileCache = new TileCache(job);
227 this.tileCache.addTileListener(this);
228 } catch(Exception e ) {
229 LOGGER.error("Failed to initialize MapNode", e);
233 private static String computeUrl() {
234 // Construct WebService from client properties
236 if (MapsClientPreferences.useBuiltinServer())
237 url = MapsClientPreferences.possibleBuiltinServerURL();
239 url = MapsClientPreferences.tileserverURL();
240 if (!url.endsWith("/"))
246 public void cleanup() {
247 MapsClientPreferences.removePreferenceChangeListenerMapsServer(listener);
252 @SyncField("enabled")
253 public void setEnabled(Boolean enabled) {
254 this.enabled = enabled;
257 @SyncField("backgroundColor")
258 public void setBackgroundColor(Color color) {
259 this.backgroundColor = color;
263 public void render(Graphics2D g2d) {
266 AffineTransform ot = g2d.getTransform();
267 //System.err.println("ot " + ot);
268 g2d.transform(transform);
270 AffineTransform tr = g2d.getTransform();
271 //System.err.println("tr " + tr);
272 // Graphics2D g = (Graphics2D)g2d.create();
273 // AffineTransform tr = (AffineTransform)g.getTransform().clone();
275 double scaleX = Math.abs(tr.getScaleX());
276 //System.err.println("scaleX : " + scaleX);
277 double scaleY = Math.abs(tr.getScaleY());
278 if (scaleX <= 0 || scaleY <= 0) {
279 // Make sure that we don't end up in an eternal loop below.
282 double offsetX = -tr.getTranslateX();
283 double offsetY = -tr.getTranslateY();
285 g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
287 Rectangle2D sp = localToControl(new Rectangle2D.Double(0.0, 0.0, 1.0, 1.0));
288 Rectangle2D b = (Rectangle2D)((Rectangle)g2d.getRenderingHint(G2DRenderingHints.KEY_CONTROL_BOUNDS)).getBounds2D(); // getClipBounds is not accurate enough, use original clipbounds and scale here
290 Rectangle2D viewbox = new Rectangle2D.Double(offsetX/scaleX, offsetY/scaleY, b.getWidth()/sp.getWidth(), b.getHeight()/sp.getHeight()); //g.getClipBounds();
293 int level = MapScalingTransform.zoomLevel(ot);
295 // double smallerViewboxDimension = viewbox.getWidth() < viewbox.getHeight() ? viewbox.getWidth() * MAP_SCALE : viewbox.getHeight() * MAP_SCALE;
297 // double tileSize = 360 * MAP_SCALE*2.5;
298 // while (level < MAX_TILE_LEVEL) {
299 // double ratio = smallerViewboxDimension / tileSize;
300 // if (ratio >= 0.85) {
307 * To convert y-coordinates to map coordinates in ruler, use:
308 * double val = (y-offsetY)/scaleY;
309 * val = Math.toDegrees(Math.atan(Math.sinh(Math.toRadians(val))));
310 * String str = formatValue(val);
313 double minx = Math.min(180, Math.max(viewbox.getMinX(), -180));
314 double maxx = Math.min(180, Math.max(viewbox.getMaxX(), -180));
316 double miny = Math.min(360, Math.max(viewbox.getMinY()+180, 0));
317 double maxy = Math.min(360, Math.max(viewbox.getMaxY()+180, 0));
318 //System.err.println("minx " + minx + " maxx " + maxx + " miny " + miny + " maxy " + maxy);
319 g2d.setTransform(new AffineTransform());
320 g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR);
322 int levels = (1 << level);
323 //System.err.println("level " + level);
324 //System.err.println("levels " + levels);
325 // http://wiki.openstreetmap.org/wiki/Slippy_map_tilenames
326 int left = (int)Math.floor( (minx + 180) / 360 * (1<<level) );
327 int right = (int)Math.floor( (maxx + 180) / 360 * (1<<level) );
328 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) );
329 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) );
330 //System.err.println("left " + left + " right " + right + " bottom " + bottom + " top " + top);
331 double tsx = 360 / (double)levels; // Size of tile on zoom level
332 //System.err.println("tileSize " + tsx);
333 for (int tx = left; tx <= right; tx++) {
334 if (tx < 0 || tx >= levels)
336 for (int ty = top; ty <= bottom; ty++) {
337 if (ty < 0 || ty >= levels)
339 TileKey tile = new TileKey(level, tx, ty);
340 double y = (double) ty - (double) levels / 2; // In level 0 we have only one tile
342 paintTile(tileCache, g2d, tr, tile, tx * tsx - 180, y * tsx, tsx);
347 // Check if transform has changed
348 transformChanged(tr, level);
350 if (backgroundColor != null) {
351 g2d.setTransform(new AffineTransform());
352 Rectangle2D controlBounds = (Rectangle2D) g2d.getRenderingHint(G2DRenderingHints.KEY_CONTROL_BOUNDS);
353 Color color = g2d.getColor();
354 g2d.setColor(backgroundColor);
355 g2d.fill(controlBounds);
359 g2d.setTransform(ot);
363 public Rectangle2D getBoundsInLocal() {
367 private void paintTile(TileCache cache, Graphics2D g, AffineTransform userToScreenTransform, TileKey tile, double tx, double ty, double tileSize) {
368 GraphicsConfiguration gc = g.getDeviceConfiguration();
371 boolean usingParentData = false;
372 helperRect.setFrame(0, 0, TILE_PIXEL_SIZE, TILE_PIXEL_SIZE);
374 TileState state = tileStates.get(tile);
376 state = TileState.NOT_LOADED;
382 // The same logic applies both when the data has not been loaded
383 // or the data has been loaded previously, but may have been
384 // flushed from the cache.
385 img = cache.peek(tile);
387 // Either the data has never existed before or the cache has
388 // flushed this tile. Mark it as needed and try to use
389 // parent image instead.
392 Pair<TileKey, Image> p = findFirstAvailableParentTile(cache, tile, tileTraverser);
395 helperRect.setFrame(0, 0, img.getWidth(observer), img.getHeight(observer));
396 traverseRectangle(tileTraverser, helperRect);
397 usingParentData = true;
399 img = getNotLoadedImage(gc);
402 helperRect.setFrame(0, 0, img.getWidth(observer), img.getHeight(observer));
407 Pair<TileKey, Image> p = findFirstAvailableParentTile(cache, tile, tileTraverser);
410 helperRect.setFrame(0, 0, img.getWidth(observer), img.getHeight(observer));
411 traverseRectangle(tileTraverser, helperRect);
412 usingParentData = true;
414 img = getLoadingImage(gc);
418 case NOT_AVAILABLE: {
419 img = getMissingImage(gc);
423 throw new Error("Invalid tile state: " + state);
426 // Calculate where these user space coordinates are in the screen
427 // space and given them to drawImage().
428 Point2D helperPoint1 = new Point2D.Double(tx, ty);
429 Point2D helperPoint2 = new Point2D.Double(tx + tileSize, ty + tileSize);
430 userToScreenTransform.transform(helperPoint1, helperPoint1);
431 userToScreenTransform.transform(helperPoint2, helperPoint2);
433 int dx1 = (int) Math.round(helperPoint1.getX());
434 int dy1 = (int) Math.round(helperPoint1.getY());
435 int dx2 = (int) Math.round(helperPoint2.getX());
436 int dy2 = (int) Math.round(helperPoint2.getY());
438 int sx1 = (int) Math.round(helperRect.getMinX());
439 int sy1 = (int) Math.round(helperRect.getMinY());
440 int sx2 = (int) Math.round(helperRect.getMaxX());
441 int sy2 = (int) Math.round(helperRect.getMaxY());
443 if (usingParentData) {
444 g.setComposite(parentDataComposite);
446 g.setComposite(normalComposite);
448 g.drawImage(img, dx1, dy1, dx2, dy2, sx1, sy1, sx2, sy2, observer);
451 private void transformChanged(AffineTransform newTransform, final int level) {
452 // Cancel data fetching if the viewport changes.
453 AffineTransform ato = (AffineTransform) oldTransform;
454 AffineTransform atn = (AffineTransform) newTransform;
458 s1 = ato.getScaleX();
459 double s2 = atn.getScaleX();
461 if (Math.abs(ds) > 1e-6) {
462 if (pendingTileTask != null) {
463 pendingTileTask.cancel(false);
464 pendingTileTask = null;
466 Runnable r = new Runnable() {
469 // There was a quiet period of [delay] milliseconds.
470 // Now its time to start fetching the map data that has
471 // piled up into the queue.
472 loadNeededTiles(level);
475 pendingTileTask = scheduler.schedule(r, VIEWBOX_QUIET_TIME, TimeUnit.MILLISECONDS);
476 oldTransform = (AffineTransform)newTransform.clone();
478 loadNeededTiles(level);
482 private void loadNeededTiles(final int level) {
483 TileCache cache = this.tileCache;
485 // Remove all tile queries from the job queue that do not match the
486 // requested tile level. This helps speed up loading of the right tiles
487 // that should be shown to the user ASAP instead of spending time
488 // loading invisible tiles.
489 cache.filterJobQueue(new IFilter<TileKey>() {
491 public boolean select(TileKey tile) {
492 boolean result = (tile.getLevel() == level);
494 // System.out.println("FILTERED TILE: " + tile);
499 // Schedule retrieval of each tile on the current level.
501 synchronized (neededTiles) {
502 l = neededTiles.get(level);
505 if (l != null && !l.isEmpty()) {
506 for (TileKey k : l) {
507 tileStates.put(k, TileState.LOADING);
513 void needTile(TileKey t) {
514 synchronized (neededTiles) {
515 int level = t.getLevel();
516 TileLevel l = neededTiles.get(level);
519 neededTiles.put(level, l);
525 private Pair<TileKey, Image> findFirstAvailableParentTile(TileCache cache, TileKey tile, TileTraverser traverser) {
529 TileKey parent = getParentTile(tile);
530 while (parent != null) {
531 Image img = cache.peek(parent);
533 return new Pair<TileKey, Image>(parent, img);
534 traverser.add(parent);
535 parent = getParentTile(parent);
540 private TileKey getParentTile(TileKey k) {
541 if (k.getLevel() == 0)
543 return new TileKey(k.getLevel() - 1, k.getX() / 2, k.getY() / 2);
546 private void traverseRectangle(TileTraverser traverser, Rectangle2D rect) {
547 for (int i = traverser.size() - 1; i >= 0; --i) {
548 traverseToQuadrant(rect, traverser.getQuadrant(i));
552 private Rectangle2D traverseToQuadrant(Rectangle2D r, int quadrant) {
553 boolean left = quadrant == 1 || quadrant == 2;
554 boolean top = quadrant == 1 || quadrant == 0;
556 left ? r.getMinX() : r.getCenterX(),
557 top ? r.getMinY() : r.getCenterY(),
559 r.getHeight() / 2.0);
563 private synchronized VolatileImage getNotLoadedImage(GraphicsConfiguration gc) {
564 return validateImageWithCenteredText(gc, notLoadedImage, TILE_PIXEL_SIZE, "NOT LOADED", Color.LIGHT_GRAY, Color.BLACK);
567 private synchronized VolatileImage getLoadingImage(GraphicsConfiguration gc) {
568 return validateImageWithCenteredText(gc, loadingImage, TILE_PIXEL_SIZE, "LOADING", Color.BLUE, Color.BLACK);
571 private synchronized VolatileImage getMissingImage(GraphicsConfiguration gc) {
572 return validateImageWithCenteredText(gc, notAvailableImage, TILE_PIXEL_SIZE, "NOT AVAILABLE", Color.RED, Color.DARK_GRAY);
575 private void drawImageWithCenteredText(VolatileImage image, String str, Font font, Color color, Color bgColor) {
576 Graphics2D target = image.createGraphics();
577 Rectangle2D bb = target.getFontMetrics(font).getStringBounds(str, target);
579 target.setBackground(new Color(255,255,255,0));
580 int w = image.getWidth();
581 int h = image.getHeight();
583 target.setColor(Color.BLACK);
584 target.fillRect(0, 0, w, h);
585 target.setColor(bgColor);
586 target.fillRect(2, 2, w - 3, h - 3);
587 target.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
588 target.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
589 target.setRenderingHint(RenderingHints.KEY_ALPHA_INTERPOLATION, RenderingHints.VALUE_ALPHA_INTERPOLATION_QUALITY);
590 target.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
591 target.setColor(color);
592 target.setFont(font);
593 target.drawString(str, (float) (((double)w - bb.getWidth()) / 2), (float) (((double)h + bb.getHeight()) / 2));
597 private VolatileImage validateImageWithCenteredText(GraphicsConfiguration gc, ObjectHolder<VolatileImage> container, int size, String text, Color textColor, Color bgColor) {
598 VolatileImage image = container.get();
600 int validateResult = image.validate(gc);
601 if (validateResult == VolatileImage.IMAGE_INCOMPATIBLE) {
603 } else if (validateResult == VolatileImage.IMAGE_OK) {
605 } else if (validateResult == VolatileImage.IMAGE_RESTORED) {
606 drawImageWithCenteredText(image, text, textFont, textColor, bgColor);
610 image = gc.createCompatibleVolatileImage(size, size);
611 container.set(image);
612 drawImageWithCenteredText(image, text, textFont, textColor, bgColor);
618 public void tileCanceled(TileKey key) {
619 tileStates.put(key, TileState.NOT_LOADED);
623 public void tileFailed(TileKey key, Throwable e) {
624 tileStates.put(key, TileState.NOT_AVAILABLE);
629 public void tileUpdated(TileKey key, Image image) {
630 tileStates.put(key, TileState.LOADED);