/******************************************************************************* * Copyright (c) 2014 Open Door Logistics (www.opendoorlogistics.com) * All rights reserved. This program and the accompanying materials * are made available under the terms of the GNU Lesser Public License v3 * which accompanies this distribution, and is available at http://www.gnu.org/licenses/lgpl.txt ******************************************************************************/ package com.opendoorlogistics.core.gis.map.tiled; import java.awt.Graphics2D; import java.awt.Image; import java.awt.Point; import java.awt.RenderingHints; import java.awt.geom.Rectangle2D; import java.awt.image.BufferedImage; import java.util.HashSet; import java.util.Iterator; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.UUID; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ThreadFactory; import java.util.concurrent.ThreadPoolExecutor; import java.util.concurrent.TimeUnit; import com.opendoorlogistics.api.geometry.LatLongToScreen; import com.opendoorlogistics.api.geometry.ODLGeom; import com.opendoorlogistics.api.ui.Disposable; import com.opendoorlogistics.codefromweb.BlockingLifoQueue; import com.opendoorlogistics.core.cache.RecentlyUsedCache; import com.opendoorlogistics.core.gis.map.CachedGeomImageRenderer; import com.opendoorlogistics.core.gis.map.DatastoreRenderer; import com.opendoorlogistics.core.gis.map.ObjectRenderer; import com.opendoorlogistics.core.gis.map.RenderProperties; import com.opendoorlogistics.core.gis.map.data.DrawableObject; import com.opendoorlogistics.core.gis.map.tiled.DrawableObjectLayer.LayerType; import com.opendoorlogistics.core.utils.Pair; import com.opendoorlogistics.core.utils.images.CompressedImage; import com.opendoorlogistics.core.utils.images.CompressedImage.CompressedType; import gnu.trove.set.hash.TLongHashSet; final public class TileCacheRenderer implements Disposable { private static final int MAX_GEOM_POINTS_FOR_EDT_RENDER = 5000; //private static final int MAX_GEOM_POINTS_FILL_FOR_EDT_RENDER = 5000; private final DatastoreRenderer EDTrenderer = new DatastoreRenderer(); // private final DatastoreRenderer workerThreadRenderer = new DatastoreRenderer(true, RecentImageCache.ZipType.LZ4); private final ObjectRenderer workerThreadRenderer = new CachedGeomImageRenderer(); private final ExecutorService service; private final RecentlyUsedCache updatedCompletedTileMap = new RecentlyUsedCache("updated-completed-tile-map",64 * 1024 * 1024); private final RecentlyUsedCache outdatedCompleteTileMap = new RecentlyUsedCache("outdated-complete-tile-map",16 * 1024 * 1024); private final ConcurrentHashMap<Object, CachedTile> processingTileMap = new ConcurrentHashMap<>(); private final HashSet<TileReadyListener> tileReadyListeners = new HashSet<>(); private final BufferedImage loadingImage = createLoadingImage(); // private TLongHashSet lastSelectedObjectIds = new TLongHashSet(); private List<DrawableObjectLayer> drawableLayers; private volatile boolean isDisposed = false; private final ChangedObjectsCalculator calculateChangedObjects = new ChangedObjectsCalculator(); private LatLongToScreen lastConverter; private boolean edtRender = false; private NOPLManager noplmanager = new NOPLManager(); @SuppressWarnings("unused") /** * We store the last required tiles so we have a strong reference to them */ private LinkedList<CachedTile> lastUsedTiles = new LinkedList<>(); public TileCacheRenderer() { // Create with a single rendering thread so we don't have to worry about synchronisation issues // We use a LIFO queue so last requested is executed first. If the user zooms around a lot // their most recent viewpoint should therefore generally be prioritised (unless they've // zoomed back and forth quickly...) int nThreads = 1; service = new ThreadPoolExecutor(nThreads, nThreads, 0L, TimeUnit.MILLISECONDS, new BlockingLifoQueue<Runnable>(), new ThreadFactory() { ThreadFactory factory = Executors.defaultThreadFactory(); @Override public Thread newThread(Runnable r) { Thread ret = factory.newThread(r); ret.setName("TileCacheRendererThread-" + UUID.randomUUID().toString()); return ret; } }); } private static BufferedImage createLoadingImage() { BufferedImage ret = new BufferedImage(TilePosition.TILE_SIZE, TilePosition.TILE_SIZE, BufferedImage.TYPE_INT_ARGB); Graphics2D g = ret.createGraphics(); g.setClip(0, 0, TilePosition.TILE_SIZE, TilePosition.TILE_SIZE); g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); g.setRenderingHint(RenderingHints.KEY_RENDERING, RenderingHints.VALUE_RENDER_QUALITY); // String message = "drawing..."; // Font loadingFont = new Font(Font.SANS_SERIF, Font.BOLD, 14); // TextLayout textLayout = new TextLayout(message, loadingFont, g.getFontRenderContext()); // int width = (int)textLayout.getBounds().getWidth(); // int x = (TILE_SIZE - width)/2; // int y = TILE_SIZE/2 - 8; // AffineTransform affineTransform = new AffineTransform(); // affineTransform.translate(x,y); // Shape shape = textLayout.getOutline(affineTransform); // BasicStroke innerStroke = new BasicStroke(4, BasicStroke.CAP_ROUND, BasicStroke.CAP_ROUND, 0, null, 0); // g.setStroke(innerStroke); // g.setColor(Color.WHITE); // g.draw(shape); // Color fontColour = new Color(0, 0, 100); // g.setColor(fontColour); // textLayout.draw(g,x,y); // Color fade = new Color(50, 50, 50, 255); // Rectangle bounds = g.getClipBounds(); // g.setColor(fade); // g.fillRect(bounds.x, bounds.y, bounds.width, bounds.height); g.dispose(); return ret; } public static interface TileReadyListener { void tileReady(Rectangle2D worldBitmapBounds, Object zoom); } /** * Information required for rendering * * @author Phil * */ private static class RenderInformation { final List<DrawableObjectLayer> layers; final LatLongToScreen originalConverter; // final long renderFlags; final TLongHashSet selectedObjectIds; RenderInformation(List<DrawableObjectLayer> layers, LatLongToScreen converter, TLongHashSet selectedObjectIds) { this.layers = layers; this.originalConverter = converter; // this.renderFlags = renderFlags; this.selectedObjectIds = selectedObjectIds; } } private class CachedTile implements Runnable { final TilePosition position; final RenderInformation renderInfo; CompressedImage finalImage; volatile boolean invalid = false; @Override public String toString() { return position.toString(); } public CachedTile(int x, int y, Object zoomKey, RenderInformation renderInfo) { this.position = new TilePosition(x, y, zoomKey); this.renderInfo = renderInfo; } @Override public int hashCode() { final int prime = 31; int result = 1; result = prime * result + ((position == null) ? 0 : position.hashCode()); return result; } @Override public boolean equals(Object obj) { if (this == obj) return true; if (obj == null) return false; if (getClass() != obj.getClass()) return false; CachedTile other = (CachedTile) obj; if (position == null) { if (other.position != null) return false; } else if (!position.equals(other.position)) return false; return true; } @Override public synchronized void run() { BufferedImage workImg = new BufferedImage(TilePosition.TILE_SIZE, TilePosition.TILE_SIZE, BufferedImage.TYPE_INT_ARGB); // ImageUtils.fillImage(workImg, new Color(200, 200, 255)); Graphics2D g = workImg.createGraphics(); try { g.setClip(0, 0, TilePosition.TILE_SIZE, TilePosition.TILE_SIZE); // create a lat-long to onscreen converter which gives gives the viewable viewport bounds as the tile LatLongToScreen converter = position.createConverter(renderInfo.originalConverter); // render objects for(DrawableObjectLayer layer:renderInfo.layers){ if(layer.getType() == LayerType.NOVLPL){ // draw the layer tile NOVLPolyLayerTile tile = noplmanager.getTile(layer.getNOVLPLGroupId(), position, converter); // check poly layer tile exists, if not then this tile is probably out-of-date if(tile!=null){ tile.draw(layer, g,renderInfo.selectedObjectIds, 0, 0, converter.getZoomForObjectFiltering()); } } else{ // draw each object one-by-one for(DrawableObject obj:layer){ // check for quitting (flagged from other thread) if (invalid || isDisposed) { return; } try { workerThreadRenderer.renderObject(g, converter, obj, renderInfo.selectedObjectIds.contains(obj.getGlobalRowId()),0); } catch (Throwable e) { } } } } // we've finished finalImage = new CompressedImage(DatastoreRenderer.postProcessImage(workImg), CompressedType.LZ4); if (!invalid && !isDisposed) { updatedCompletedTileMap.put(this, this, this.finalImage.getSizeBytes()); outdatedCompleteTileMap.put(this, this, this.finalImage.getSizeBytes()); } fireTileReadyListeners(this); } finally { g.dispose(); processingTileMap.remove(this); } } void setInvalid() { invalid = true; } } static class MinMaxTileIndices { final Point minTileIndex; final Point maxTileIndex; MinMaxTileIndices(LatLongToScreen converter) { Rectangle2D view = converter.getViewportWorldBitmapScreenPosition(); Point minPixelPoint = new Point((int) Math.floor(view.getX()), (int) Math.floor(view.getY())); Point maxPixelPoint = new Point((int) Math.ceil(view.getX() + view.getWidth()), (int) Math.ceil(view.getY() + view.getHeight())); // get min and max in tile indices minTileIndex = toTileIndexPoint(minPixelPoint); maxTileIndex = toTileIndexPoint(maxPixelPoint); } boolean isWithin(int ix, int iy) { return ix >= minTileIndex.x && ix <= maxTileIndex.x && iy >= minTileIndex.y && iy <= maxTileIndex.x; } } public synchronized void renderObjects(Graphics2D g, LatLongToScreen converter, long renderFlags, TLongHashSet selectedObjectIds) { // always take fresh copy of selection object so our internal record can // be considered immutable if (selectedObjectIds == null) { selectedObjectIds = new TLongHashSet(); } clearDirtyTiles(calculateChangedObjects.updateSelected(selectedObjectIds), lastConverter); LinkedList<CachedTile> newLastUsedTiles = new LinkedList<>(); if (edtRender == false) { // get the world bitmap for the current zoom MinMaxTileIndices tileRange = new MinMaxTileIndices(converter); Rectangle2D view = converter.getViewportWorldBitmapScreenPosition(); // save information required for rendering in an object RenderInformation renderInformation = new RenderInformation(drawableLayers, converter, selectedObjectIds); // loop over all visible tile positions Object zoomKey = converter.getZoomHashmapKey(); for (int ix = tileRange.minTileIndex.x; ix <= tileRange.maxTileIndex.x; ix++) { for (int iy = tileRange.minTileIndex.y; iy <= tileRange.maxTileIndex.y; iy++) { // create empty tile then use as a key to see if already exists CachedTile tile = new CachedTile(ix, iy, zoomKey, renderInformation); CachedTile working = processingTileMap.get(tile); CachedTile complete = (CachedTile) updatedCompletedTileMap.get(tile); Image image = loadingImage; if (complete != null) { image = complete.finalImage.get(); // newLastUsedTiles.add(complete); } else { // use an old tile if we have it; will look better than completely redrawing // the screen on each small change... CachedTile outdated = (CachedTile) outdatedCompleteTileMap.get(tile); if (outdated != null && outdated.finalImage != null) { image = outdated.finalImage.get(); } // // Use the tile from the last frame if still around; it will be wrong // // but better than nothing and won't confuse the user as it will be exactly // // the same as the last frame (i.e. instead of seeing a blank they'll see nothing change). // if(lastUsedTiles!=null){ // for(Tile lut : lastUsedTiles){ // if(lut.ix == ix && lut.iy == iy && lut.zoomKey.equals(zoomKey)){ // System.out.println("xy zoom key match"); // } // // if(lut.zoomKey.equals(zoomKey) && lut.ix == ix && lut.iy == iy && lut.finalImage!=null){ // image = lut.finalImage.get(); // } // } // } // if (working == null) { // we need to create it processingTileMap.put(tile, tile); service.submit(tile); newLastUsedTiles.add(tile); } else { newLastUsedTiles.add(working); } } g.drawImage(image, (int) (tile.position.ix * TilePosition.TILE_SIZE - view.getX()), (int) (tile.position.iy * TilePosition.TILE_SIZE - view.getY()), null); } } } // we maintain a non-soft reference to the last tiles used so they can't // get garbage collected after being completed and before being drawn to screen lastUsedTiles = newLastUsedTiles; if (edtRender) { EDTrenderer.renderAll(g, DrawableObjectLayer.layers2SingleList(drawableLayers), converter, renderFlags|RenderProperties.DRAW_OSM_COPYRIGHT, selectedObjectIds); } else { // Render text uncached without tiles at the moment. As texts can stretch over two tiles we need to // compare results across multiple tiles to see what text doesn't overlap what, and hence // what should be visible. As this is problematic we don't render text in the tiles. if ((renderFlags & RenderProperties.SHOW_TEXT) == RenderProperties.SHOW_TEXT) { EDTrenderer.renderTexts(g, DrawableObjectLayer.layers2SingleList(drawableLayers), converter, RenderProperties.DRAW_OSM_COPYRIGHT); } } // Save the last converter lastConverter = converter; } private static Point toTileIndexPoint(Point pixelPoint) { return new Point(pixelPoint.x / TilePosition.TILE_SIZE, pixelPoint.y / TilePosition.TILE_SIZE); } @Override public void dispose() { isDisposed = true; service.shutdownNow(); // service.shutdown(); } // /** // * Test if the geometry contains one or more polygons // * // * @param geometry // * @return // */ // private static boolean hasPolygon(Geometry geometry) { // if (geometry != null) { // if (GeometryCollection.class.isInstance(geometry)) { // int n = geometry.getNumGeometries(); // for (int i = 0; i < n; i++) { // if (hasPolygon(geometry.getGeometryN(i))) { // return true; // } // } // } else { // return Polygon.class.isInstance(geometry); // } // } // return false; // } public synchronized void setObjects(Iterable<? extends DrawableObject> pnts) { // Clear all tiles which are no longer correct clearDirtyTiles(calculateChangedObjects.updateObjects(pnts), lastConverter); // Count the number of geometry points. We render on the EDT for a small number // of points as this makes the map more responsive long geomPointsCount = 0; edtRender = false; for (DrawableObject obj : pnts) { ODLGeom geom = obj.getGeometry(); if (geom == null) { geomPointsCount++; } else { geomPointsCount += geom.getPointsCount(); } } // Choose EDT render or not if (geomPointsCount < MAX_GEOM_POINTS_FOR_EDT_RENDER ) { edtRender = true; } // Update the layer manager drawableLayers = noplmanager.update(pnts); } private synchronized void clearTiles() { // set invalid so the running tile can't be added to completed later clearProcessingTiles(); updatedCompletedTileMap.clear(); } private void clearProcessingTiles() { for (CachedTile tile : processingTileMap.values()) { tile.setInvalid(); } processingTileMap.clear(); } public synchronized void addTileReadyListener(TileReadyListener listener) { tileReadyListeners.add(listener); } public synchronized void removeTileReadyListener(TileReadyListener listener) { tileReadyListeners.remove(listener); } private synchronized void fireTileReadyListeners(CachedTile tile) { Rectangle2D bounds = tile.position.createBounds(); for (TileReadyListener listener : tileReadyListeners) { listener.tileReady(bounds, tile.position.zoomKey); } } public static void main(String[] args) { // ImageUtils.createImageFrame(createLoadingImage()).setVisible(true); } private void clearDirtyTiles(List<DrawableObject> changeset, final LatLongToScreen currentView) { boolean clearAll = false; // clear all if haven't got current view if (currentView == null) { clearAll = true; } // calculate change area for the current view Rectangle2D changeArea = null; if (!clearAll) { for (DrawableObject obj : changeset) { Rectangle2D bounds = DatastoreRenderer.getRenderedWorldBitmapBounds(obj, currentView); if (bounds != null) { if (changeArea == null) { changeArea = bounds; } else { changeArea = changeArea.createUnion(bounds); } } } } if (clearAll) { clearTiles(); } else { class TileTester{ boolean isInvalid(CachedTile tile, Rectangle2D changeArea){ // keep those in current view outside of change area boolean clearTile = false; // clear tile if not in current zoom as change area only calculated for this if (tile.position.zoomKey.equals(currentView.getZoomHashmapKey()) == false) { clearTile = true; } // clear tile if bounds intersect change area if (!clearTile && changeArea != null && tile.position.createBounds().intersects(changeArea)) { clearTile = true; } return clearTile; } } TileTester tileTester = new TileTester(); // parse completed tiles for (Pair<Object, Object> pair : updatedCompletedTileMap.getSnapshot()) { CachedTile tile = (CachedTile) pair.getSecond(); // clear tile if not in current zoom as change area only calculated for this boolean clearTile = tileTester.isInvalid(tile, changeArea); if (clearTile) { updatedCompletedTileMap.remove(tile); } } // parse processing tiles as well Iterator<Map.Entry<Object,CachedTile>> it=processingTileMap.entrySet().iterator(); while(it.hasNext()){ CachedTile tile = it.next().getValue(); if(tileTester.isInvalid(tile, changeArea)){ tile.invalid = true; it.remove(); } } } } public boolean isDisposed() { return isDisposed; } }