/******************************************************************************* * Copyright (c) 2012 Association for Decentralized Information Management * in Industry THTH ry. * All rights reserved. This program and the accompanying materials * are made available under the terms of the Eclipse Public License v1.0 * which accompanies this distribution, and is available at * http://www.eclipse.org/legal/epl-v10.html * * Contributors: * VTT Technical Research Centre of Finland - initial API and implementation *******************************************************************************/ package org.simantics.maps.sg; import java.awt.AlphaComposite; import java.awt.Color; import java.awt.Composite; import java.awt.Font; import java.awt.Graphics2D; import java.awt.GraphicsConfiguration; import java.awt.Image; import java.awt.Rectangle; import java.awt.RenderingHints; import java.awt.geom.AffineTransform; import java.awt.geom.Point2D; import java.awt.geom.Rectangle2D; import java.awt.image.ImageObserver; import java.awt.image.VolatileImage; import java.util.ArrayList; import java.util.HashMap; import java.util.HashSet; import java.util.Hashtable; import java.util.List; import java.util.Map; import java.util.concurrent.Executors; import java.util.concurrent.ScheduledExecutorService; import java.util.concurrent.ScheduledFuture; import java.util.concurrent.TimeUnit; import org.simantics.maps.WebService; import org.simantics.maps.osm.OSMTileProvider; import org.simantics.maps.pojo.TileJobQueue; import org.simantics.maps.prefs.MapsClientPreferences; import org.simantics.maps.tile.IFilter; import org.simantics.maps.tile.ITileJobQueue; import org.simantics.maps.tile.ITileListener; import org.simantics.maps.tile.ITileProvider; import org.simantics.maps.tile.TileCache; import org.simantics.maps.tile.TileKey; import org.simantics.scenegraph.g2d.G2DNode; import org.simantics.scenegraph.g2d.G2DRenderingHints; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** * @author Tuukka Lehtonen */ public class MapNode extends G2DNode implements ITileListener { private static final long serialVersionUID = 2490944880914577411L; private static final Logger LOGGER = LoggerFactory.getLogger(MapNode.class); private final double MAP_SCALE = 1; private final int MAX_TILE_LEVEL = 19; private final int TILE_PIXEL_SIZE = 256; private final int VIEWBOX_QUIET_TIME = 500; protected Boolean enabled = true; enum TileState { NOT_LOADED, LOADING, LOADED, NOT_AVAILABLE } class TileLevel extends HashSet { private static final long serialVersionUID = 5743763238677223952L; } static class TileTraverser { List tile = new ArrayList(4); List quadrant = new ArrayList(4); static int getTileQuadrant(TileKey k) { int x = k.getX(); int y = k.getY(); if ((x & 1) == 0) { return ((y & 1) == 0) ? 1 : 2; } return ((y & 1) == 0) ? 0 : 3; } void clear() { tile.clear(); quadrant.clear(); } void add(TileKey tile) { this.tile.add(tile); this.quadrant.add(getTileQuadrant(tile)); } int size() { return tile.size(); } TileKey getTile(int i) { return tile.get(i); } int getQuadrant(int i) { return quadrant.get(i); } } public class ObjectHolder { private T o = null; public T get() { return o; } public void set(T o) { this.o = o; } } public final class Pair { public final T1 first; public final T2 second; public Pair(T1 first, T2 second) { this.first = first; this.second = second; } } private TileCache tileCache; // For delaying the retrieval of map data until the viewport remains steady private ScheduledFuture pendingTileTask; private Map neededTiles = new HashMap(); private TileTraverser tileTraverser = new TileTraverser(); private ITileJobQueue job = null; private Map tileStates = new Hashtable(); private ObjectHolder notLoadedImage = new ObjectHolder(); private ObjectHolder loadingImage = new ObjectHolder(); private ObjectHolder notAvailableImage = new ObjectHolder(); private Font textFont = new Font(Font.SANS_SERIF, Font.BOLD, 40); private AffineTransform oldTransform = new AffineTransform(); private final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); private Composite normalComposite = AlphaComposite.SrcOver.derive(1f); private Composite parentDataComposite = AlphaComposite.SrcOver.derive(0.75f); private Rectangle2D helperRect = new Rectangle2D.Double(); ImageObserver observer = new ImageObserver() { @Override public boolean imageUpdate(Image img, int infoflags, int x, int y, int width, int height) { // System.out.format("imageUpdate(%s, %d, %d, %d, %d, %d)\n", img.toString(), infoflags, x, y, width, height); return true; } }; public void init() { // Construct WebService from client properties String url; if (MapsClientPreferences.useBuiltinServer()) url = MapsClientPreferences.possibleBuiltinServerURL(); else url = MapsClientPreferences.tileserverURL(); if (!url.endsWith("/")) url = url + "/"; try { ITileProvider provider = new OSMTileProvider(new WebService(url), TILE_PIXEL_SIZE); // Try to load eclipse specific implementation of TileJobQueue, if it doesn't exist, fall back to pojo implementation try { Class proxyClass = (Class) Class.forName("org.simantics.maps.eclipse.TileJobQueue"); job = (ITileJobQueue)proxyClass.newInstance(); } catch (ClassNotFoundException e1) { } catch (InstantiationException e) { } catch (IllegalAccessException e) { } // AppletProxyUtil should exist always.. if(job == null) { job = new TileJobQueue(); } // Cache is also eclipse specific... ITileProvider cachedProvider = null; try { Class proxyClass = (Class) Class.forName("org.simantics.maps.eclipse.DiskCachingTileProvider"); cachedProvider = (ITileProvider)proxyClass.getConstructor(ITileProvider.class, Boolean.class).newInstance(provider, false); } catch (ClassNotFoundException e1) { } catch (InstantiationException e) { } catch (IllegalAccessException e) { } if(cachedProvider != null) { job.setTileProvider(cachedProvider); } else { job.setTileProvider(provider); } this.tileCache = new TileCache(job); this.tileCache.addTileListener(this); } catch(Exception e ) { LOGGER.error("Failed to initialize MapNode", e); } } @SyncField("enabled") public void setEnabled(Boolean enabled) { this.enabled = enabled; } @Override public void render(Graphics2D g2d) { if (!enabled) return; AffineTransform ot = g2d.getTransform(); g2d.transform(transform); AffineTransform tr = g2d.getTransform(); // Graphics2D g = (Graphics2D)g2d.create(); // AffineTransform tr = (AffineTransform)g.getTransform().clone(); double scaleX = Math.abs(tr.getScaleX()); double scaleY = Math.abs(tr.getScaleY()); if (scaleX <= 0 || scaleY <= 0) { // Make sure that we don't end up in an eternal loop below. return; } double offsetX = -tr.getTranslateX(); double offsetY = -tr.getTranslateY(); g2d.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); Rectangle2D sp = localToControl(new Rectangle2D.Double(0.0, 0.0, 1.0, 1.0)); Rectangle2D b = (Rectangle2D)((Rectangle)g2d.getRenderingHint(G2DRenderingHints.KEY_CONTROL_BOUNDS)).getBounds2D(); // getClipBounds is not accurate enough, use original clipbounds and scale here Rectangle2D viewbox = new Rectangle2D.Double(offsetX/scaleX, offsetY/scaleY, b.getWidth()/sp.getWidth(), b.getHeight()/sp.getHeight()); //g.getClipBounds(); if(viewbox == null) return; // FIXME double smallerViewboxDimension = viewbox.getWidth() < viewbox.getHeight() ? viewbox.getWidth() * MAP_SCALE : viewbox.getHeight() * MAP_SCALE; int level = 0; double tileSize = 360 * MAP_SCALE*2; while (level < MAX_TILE_LEVEL) { double ratio = smallerViewboxDimension / tileSize; if (ratio >= 0.85) { break; } tileSize *= 0.5; level++; } /* * To convert y-coordinates to map coordinates in ruler, use: * double val = (y-offsetY)/scaleY; * val = Math.toDegrees(Math.atan(Math.sinh(Math.toRadians(val)))); * String str = formatValue(val); */ double minx = Math.min(180, Math.max(viewbox.getMinX(), -180)); double maxx = Math.min(180, Math.max(viewbox.getMaxX(), -180)); double miny = Math.min(360, Math.max(viewbox.getMinY()+180, 0)); double maxy = Math.min(360, Math.max(viewbox.getMaxY()+180, 0)); g2d.setTransform(new AffineTransform()); g2d.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); int levels = (1 << level); // http://wiki.openstreetmap.org/wiki/Slippy_map_tilenames int left = (int)Math.floor( (minx + 180) / 360 * (1<= levels) continue; for(int ty = top; ty <= bottom; ty++) { if(ty < 0 || ty >= levels) continue; TileKey tile = new TileKey(level, tx, ty); double y = (double)ty - (double)levels/2; // In level 0 we have only one tile paintTile(tileCache, g2d, tr, tile, tx*tsx-180, y*tsx, tsx); } } // g.dispose(); // Check if transform has changed transformChanged(tr, level); g2d.setTransform(ot); } @Override public Rectangle2D getBoundsInLocal() { return null; } private void paintTile(TileCache cache, Graphics2D g, AffineTransform userToScreenTransform, TileKey tile, double tx, double ty, double tileSize) { GraphicsConfiguration gc = g.getDeviceConfiguration(); Image img = null; boolean usingParentData = false; helperRect.setFrame(0, 0, TILE_PIXEL_SIZE, TILE_PIXEL_SIZE); TileState state = tileStates.get(tile); if (state == null) state = TileState.NOT_LOADED; switch (state) { case NOT_LOADED: case LOADED: { // The same logic applies both when the data has not been loaded // or the data has been loaded previously, but may have been // flushed from the cache. img = cache.peek(tile); if (img == null) { // Either the data has never existed before or the cache has // flushed this tile. Mark it as needed and try to use // parent image instead. needTile(tile); Pair p = findFirstAvailableParentTile(cache, tile, tileTraverser); if (p != null) { img = p.second; helperRect.setFrame(0, 0, img.getWidth(observer), img.getHeight(observer)); traverseRectangle(tileTraverser, helperRect); usingParentData = true; } else { img = getNotLoadedImage(gc); } } else { helperRect.setFrame(0, 0, img.getWidth(observer), img.getHeight(observer)); } break; } case LOADING: { Pair p = findFirstAvailableParentTile(cache, tile, tileTraverser); if (p != null) { img = p.second; helperRect.setFrame(0, 0, img.getWidth(observer), img.getHeight(observer)); traverseRectangle(tileTraverser, helperRect); usingParentData = true; } else { img = getLoadingImage(gc); } break; } case NOT_AVAILABLE: { img = getMissingImage(gc); break; } default: throw new Error("Invalid tile state: " + state); } // Calculate where these user space coordinates are in the screen // space and given them to drawImage(). Point2D helperPoint1 = new Point2D.Double(tx, ty); Point2D helperPoint2 = new Point2D.Double(tx + tileSize, ty + tileSize); userToScreenTransform.transform(helperPoint1, helperPoint1); userToScreenTransform.transform(helperPoint2, helperPoint2); int dx1 = (int) Math.round(helperPoint1.getX()); int dy1 = (int) Math.round(helperPoint1.getY()); int dx2 = (int) Math.round(helperPoint2.getX()); int dy2 = (int) Math.round(helperPoint2.getY()); int sx1 = (int) Math.round(helperRect.getMinX()); int sy1 = (int) Math.round(helperRect.getMinY()); int sx2 = (int) Math.round(helperRect.getMaxX()); int sy2 = (int) Math.round(helperRect.getMaxY()); if (usingParentData) { g.setComposite(parentDataComposite); } else { g.setComposite(normalComposite); } g.drawImage(img, dx1, dy1, dx2, dy2, sx1, sy1, sx2, sy2, observer); } private void transformChanged(AffineTransform newTransform, final int level) { // Cancel data fetching if the viewport changes. AffineTransform ato = (AffineTransform) oldTransform; AffineTransform atn = (AffineTransform) newTransform; double s1 = -1; if (ato != null) s1 = ato.getScaleX(); double s2 = atn.getScaleX(); double ds = s2 - s1; if (Math.abs(ds) > 1e-6) { if (pendingTileTask != null) { pendingTileTask.cancel(false); pendingTileTask = null; } Runnable r = new Runnable() { @Override public void run() { // There was a quiet period of [delay] milliseconds. // Now its time to start fetching the map data that has // piled up into the queue. loadNeededTiles(level); } }; pendingTileTask = scheduler.schedule(r, VIEWBOX_QUIET_TIME, TimeUnit.MILLISECONDS); oldTransform = (AffineTransform)newTransform.clone(); } else { loadNeededTiles(level); } } private void loadNeededTiles(final int level) { TileCache cache = this.tileCache; // Remove all tile queries from the job queue that do not match the // requested tile level. This helps speed up loading of the right tiles // that should be shown to the user ASAP instead of spending time // loading invisible tiles. cache.filterJobQueue(new IFilter() { @Override public boolean select(TileKey tile) { boolean result = (tile.getLevel() == level); //if (!result) // System.out.println("FILTERED TILE: " + tile); return result; } }); // Schedule retrieval of each tile on the current level. TileLevel l = null; synchronized (neededTiles) { l = neededTiles.get(level); neededTiles.clear(); } if (l != null && !l.isEmpty()) { for (TileKey k : l) { tileStates.put(k, TileState.LOADING); tileCache.get(k); } } } void needTile(TileKey t) { synchronized (neededTiles) { int level = t.getLevel(); TileLevel l = neededTiles.get(level); if (l == null) { l = new TileLevel(); neededTiles.put(level, l); } l.add(t); } } private Pair findFirstAvailableParentTile(TileCache cache, TileKey tile, TileTraverser traverser) { traverser.clear(); traverser.add(tile); TileKey parent = getParentTile(tile); while (parent != null) { Image img = cache.peek(parent); if (img != null) return new Pair(parent, img); traverser.add(parent); parent = getParentTile(parent); } return null; } private TileKey getParentTile(TileKey k) { if (k.getLevel() == 0) return null; return new TileKey(k.getLevel() - 1, k.getX() / 2, k.getY() / 2); } private void traverseRectangle(TileTraverser traverser, Rectangle2D rect) { for (int i = traverser.size() - 1; i >= 0; --i) { traverseToQuadrant(rect, traverser.getQuadrant(i)); } } private Rectangle2D traverseToQuadrant(Rectangle2D r, int quadrant) { boolean left = quadrant == 1 || quadrant == 2; boolean top = quadrant == 1 || quadrant == 0; r.setFrame( left ? r.getMinX() : r.getCenterX(), top ? r.getMinY() : r.getCenterY(), r.getWidth() / 2.0, r.getHeight() / 2.0); return r; } private synchronized VolatileImage getNotLoadedImage(GraphicsConfiguration gc) { return validateImageWithCenteredText(gc, notLoadedImage, TILE_PIXEL_SIZE, "NOT LOADED", Color.LIGHT_GRAY, Color.BLACK); } private synchronized VolatileImage getLoadingImage(GraphicsConfiguration gc) { return validateImageWithCenteredText(gc, loadingImage, TILE_PIXEL_SIZE, "LOADING", Color.BLUE, Color.BLACK); } private synchronized VolatileImage getMissingImage(GraphicsConfiguration gc) { return validateImageWithCenteredText(gc, notAvailableImage, TILE_PIXEL_SIZE, "NOT AVAILABLE", Color.RED, Color.DARK_GRAY); } private void drawImageWithCenteredText(VolatileImage image, String str, Font font, Color color, Color bgColor) { Graphics2D target = image.createGraphics(); Rectangle2D bb = target.getFontMetrics(font).getStringBounds(str, target); target.setBackground(new Color(255,255,255,0)); int w = image.getWidth(); int h = image.getHeight(); target.setColor(Color.BLACK); target.fillRect(0, 0, w, h); target.setColor(bgColor); target.fillRect(2, 2, w - 3, h - 3); target.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); target.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC); target.setRenderingHint(RenderingHints.KEY_ALPHA_INTERPOLATION, RenderingHints.VALUE_ALPHA_INTERPOLATION_QUALITY); target.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON); target.setColor(color); target.setFont(font); target.drawString(str, (float) (((double)w - bb.getWidth()) / 2), (float) (((double)h + bb.getHeight()) / 2)); target.dispose(); } private VolatileImage validateImageWithCenteredText(GraphicsConfiguration gc, ObjectHolder container, int size, String text, Color textColor, Color bgColor) { VolatileImage image = container.get(); if (image != null) { int validateResult = image.validate(gc); if (validateResult == VolatileImage.IMAGE_INCOMPATIBLE) { image = null; } else if (validateResult == VolatileImage.IMAGE_OK) { return image; } else if (validateResult == VolatileImage.IMAGE_RESTORED) { drawImageWithCenteredText(image, text, textFont, textColor, bgColor); } } if (image == null) { image = gc.createCompatibleVolatileImage(size, size); container.set(image); drawImageWithCenteredText(image, text, textFont, textColor, bgColor); } return image; } @Override public void tileCanceled(TileKey key) { tileStates.put(key, TileState.NOT_LOADED); } @Override public void tileFailed(TileKey key, Throwable e) { tileStates.put(key, TileState.NOT_AVAILABLE); repaint(); } @Override public void tileUpdated(TileKey key, Image image) { tileStates.put(key, TileState.LOADED); repaint(); } }