From d8cb74026693b27f94b1ffbf72bc2071530165f4 Mon Sep 17 00:00:00 2001 From: "taylor.smock" Date: Thu, 8 Aug 2024 21:04:30 +0000 Subject: [PATCH] Fix #11487: Have josm render data to tiles This adds a new rendering method that renders async. This avoids blocking the UI. Where this is useful: * Large datasets (think county or country level) Where this is ''not'' useful: * Micromapping -- the tiles aren't being rendered ''exactly'' where they should be and there are some minor rendering artifacts. Known issues: * Some tiles aren't exactly where they should be (off by a pixel or two -- by default, we use the old render method at z16+) * Rendering of tiles is slow -- there is ''some'' prework done to render tiles in batches. The primary reason rendering is slow is we are effectively rendering 25 total tiles (to avoid movement of text, we render 2 tiles in each directory and only keep the middle one) * Due to the above speed issue, hovering over an object will cause the highlight to render in slowly. New advanced preferences: * `mappaint.fast_render.tile_size` -- controls the number of pixels in a tile * `mappaint.fast_render.zlevel` -- controls the maximum z level at which tiles are generated git-svn-id: https://josm.openstreetmap.de/svn/trunk@19176 0c6e7542-c601-0410-84e7-c038aed88b3b --- .../josm/actions/TiledRenderToggleAction.java | 69 ++++ .../data/osm/visitor/paint/ImageCache.java | 68 ++++ .../osm/visitor/paint/MapRendererFactory.java | 18 +- .../visitor/paint/StyledTiledMapRenderer.java | 350 ++++++++++++++++++ .../josm/data/osm/visitor/paint/TileZXY.java | 169 +++++++++ src/org/openstreetmap/josm/gui/MainMenu.java | 9 + src/org/openstreetmap/josm/gui/MapView.java | 14 +- .../josm/gui/layer/OsmDataLayer.java | 200 +++++++++- 8 files changed, 889 insertions(+), 8 deletions(-) create mode 100644 src/org/openstreetmap/josm/actions/TiledRenderToggleAction.java create mode 100644 src/org/openstreetmap/josm/data/osm/visitor/paint/ImageCache.java create mode 100644 src/org/openstreetmap/josm/data/osm/visitor/paint/StyledTiledMapRenderer.java create mode 100644 src/org/openstreetmap/josm/data/osm/visitor/paint/TileZXY.java diff --git a/src/org/openstreetmap/josm/actions/TiledRenderToggleAction.java b/src/org/openstreetmap/josm/actions/TiledRenderToggleAction.java new file mode 100644 index 00000000000..8a8fc520d81 --- /dev/null +++ b/src/org/openstreetmap/josm/actions/TiledRenderToggleAction.java @@ -0,0 +1,69 @@ +// License: GPL. For details, see LICENSE file. +package org.openstreetmap.josm.actions; + +import static org.openstreetmap.josm.tools.I18n.tr; + +import java.awt.event.ActionEvent; +import java.awt.event.KeyEvent; + +import org.openstreetmap.josm.data.osm.visitor.paint.MapRendererFactory; +import org.openstreetmap.josm.data.osm.visitor.paint.StyledMapRenderer; +import org.openstreetmap.josm.data.osm.visitor.paint.StyledTiledMapRenderer; +import org.openstreetmap.josm.gui.MainApplication; +import org.openstreetmap.josm.gui.layer.OsmDataLayer; +import org.openstreetmap.josm.tools.Shortcut; + +/** + * This class enables and disables tiled rendering mode. + * This is intended to be short-term until the tiled rendering + * has no significant issues at high zoom levels. + * @since 19176 + */ +public class TiledRenderToggleAction extends ToggleAction implements ExpertToggleAction.ExpertModeChangeListener { + /** + * Create a new action for toggling render methods + */ + public TiledRenderToggleAction() { + super(tr("Tiled Rendering"), + null, + tr("Enable/disable rendering the map in tiles"), + Shortcut.registerShortcut("menu:view:tiled", tr("View: {0}", tr("Tiled View")), KeyEvent.CHAR_UNDEFINED, Shortcut.NONE), + false /* register toolbar */ + ); + setToolbarId("tiledRendering"); + MainApplication.getToolbar().register(this); + setSelected(false); // Always start disabled (until we are confident in the renderer) + if (MapRendererFactory.getInstance().isMapRendererActive(StyledTiledMapRenderer.class)) { + MapRendererFactory.getInstance().activate(StyledMapRenderer.class); + } + ExpertToggleAction.addExpertModeChangeListener(this, true); + } + + @Override + protected boolean listenToSelectionChange() { + return false; + } + + @Override + public void expertChanged(boolean isExpert) { + this.updateEnabledState(); + } + + @Override + protected void updateEnabledState() { + setEnabled(getLayerManager().getActiveData() != null && ExpertToggleAction.isExpert()); + } + + @Override + public void actionPerformed(ActionEvent e) { + toggleSelectedState(e); + if (isSelected()) { + MapRendererFactory.getInstance().activate(StyledTiledMapRenderer.class); + } else { + MapRendererFactory.getInstance().activate(StyledMapRenderer.class); + } + + notifySelectedState(); + getLayerManager().getLayersOfType(OsmDataLayer.class).forEach(OsmDataLayer::invalidate); + } +} diff --git a/src/org/openstreetmap/josm/data/osm/visitor/paint/ImageCache.java b/src/org/openstreetmap/josm/data/osm/visitor/paint/ImageCache.java new file mode 100644 index 00000000000..b40e04219ef --- /dev/null +++ b/src/org/openstreetmap/josm/data/osm/visitor/paint/ImageCache.java @@ -0,0 +1,68 @@ +// License: GPL. For details, see LICENSE file. +package org.openstreetmap.josm.data.osm.visitor.paint; + +import java.awt.Image; + +import jakarta.annotation.Nullable; + +/** + * A record for keeping the image information for a tile. Used in conjunction with {@link TileZXY} for + * {@link org.openstreetmap.josm.data.cache.JCSCacheManager}. + * @since 19176 + */ +public final class ImageCache { + private final boolean isDirty; + private final StyledTiledMapRenderer.TileLoader imageFuture; + private final Image image; + /** + * Create a new {@link ImageCache} object + * @param image The image to paint (optional; either this or {@link #imageFuture} must be specified) + * @param imageFuture The future for the image (optional; either this or {@link #image} must be specified) + * @param isDirty {@code true} if the tile needs to be repainted + */ + ImageCache(Image image, StyledTiledMapRenderer.TileLoader imageFuture, boolean isDirty) { + this.image = image; + this.imageFuture = imageFuture; + this.isDirty = isDirty; + if (image == null && imageFuture == null) { + throw new IllegalArgumentException("Either image or imageFuture must be non-null"); + } + } + + /** + * Check if this tile is dirty + * @return {@code true} if this is a dirty tile + */ + public boolean isDirty() { + return this.isDirty; + } + + /** + * Mark this tile as dirty + * @return The tile to put in the cache + */ + public ImageCache becomeDirty() { + if (this.isDirty) { + return this; + } + return new ImageCache(this.image, this.imageFuture, true); + } + + /** + * Get the image to paint + * @return The image (may be {@code null}) + */ + @Nullable + public Image image() { + return this.image; + } + + /** + * Get the image future + * @return The image future (may be {@code null}) + */ + @Nullable + StyledTiledMapRenderer.TileLoader imageFuture() { + return this.imageFuture; + } +} diff --git a/src/org/openstreetmap/josm/data/osm/visitor/paint/MapRendererFactory.java b/src/org/openstreetmap/josm/data/osm/visitor/paint/MapRendererFactory.java index 5276b2a0707..0dc6787184b 100644 --- a/src/org/openstreetmap/josm/data/osm/visitor/paint/MapRendererFactory.java +++ b/src/org/openstreetmap/josm/data/osm/visitor/paint/MapRendererFactory.java @@ -191,6 +191,11 @@ private void registerDefaultRenderers() { tr("Styled Map Renderer"), tr("Renders the map using style rules in a set of style sheets.") ); + register( + StyledTiledMapRenderer.class, + tr("Styled Map Renderer (tiled)"), + tr("Renders the map using style rules in a set of style sheets by tile.") + ); } /** @@ -318,6 +323,17 @@ public List getMapRendererDescriptors() { * @return true, if currently the wireframe map renderer is active. Otherwise, false */ public boolean isWireframeMapRendererActive() { - return WireframeMapRenderer.class.equals(activeRenderer); + return isMapRendererActive(WireframeMapRenderer.class); + } + + /** + *

Replies true, if currently the specified map renderer is active. Otherwise, false.

+ * + * @param clazz The class that we are checking to see if it is the current renderer + * @return true, if currently the wireframe map renderer is active. Otherwise, false + * @since 19176 + */ + public boolean isMapRendererActive(Class clazz) { + return clazz.equals(activeRenderer); } } diff --git a/src/org/openstreetmap/josm/data/osm/visitor/paint/StyledTiledMapRenderer.java b/src/org/openstreetmap/josm/data/osm/visitor/paint/StyledTiledMapRenderer.java new file mode 100644 index 00000000000..b230cfe7a4b --- /dev/null +++ b/src/org/openstreetmap/josm/data/osm/visitor/paint/StyledTiledMapRenderer.java @@ -0,0 +1,350 @@ +// License: GPL. For details, see LICENSE file. +package org.openstreetmap.josm.data.osm.visitor.paint; + +import static org.openstreetmap.josm.tools.I18n.tr; + +import java.awt.AlphaComposite; +import java.awt.Color; +import java.awt.Font; +import java.awt.Graphics2D; +import java.awt.Image; +import java.awt.Point; +import java.awt.RenderingHints; +import java.awt.Transparency; +import java.awt.event.MouseEvent; +import java.awt.geom.AffineTransform; +import java.awt.image.BufferedImage; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Comparator; +import java.util.IntSummaryStatistics; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.concurrent.Executor; +import java.util.function.Consumer; +import java.util.stream.Collectors; + +import org.apache.commons.jcs3.access.CacheAccess; +import org.openstreetmap.josm.data.Bounds; +import org.openstreetmap.josm.data.coor.LatLon; +import org.openstreetmap.josm.data.osm.OsmData; +import org.openstreetmap.josm.data.projection.ProjectionRegistry; +import org.openstreetmap.josm.gui.MainApplication; +import org.openstreetmap.josm.gui.MapView; +import org.openstreetmap.josm.gui.NavigatableComponent; +import org.openstreetmap.josm.spi.preferences.Config; +import org.openstreetmap.josm.tools.Logging; + +/** + * A styled render that does the rendering on a tile basis. Note: this is currently experimental! + * It may be extracted to an interface at a later date. + * @since 19176 + */ +public final class StyledTiledMapRenderer extends StyledMapRenderer { + // Render to the surrounding tiles for continuity -- this probably needs to be tweaked + private static final int BUFFER_TILES = 2; + // The number of extra pixels to render per tile (avoids black lines in render result) + private static final int BUFFER_PIXELS = 16; + private CacheAccess cache; + private int zoom; + private Consumer notifier; + + /** + * Constructs a new {@code StyledMapRenderer}. + * + * @param g the graphics context. Must not be null. + * @param nc the map viewport. Must not be null. + * @param isInactiveMode if true, the paint visitor shall render OSM objects such that they + * look inactive. Example: rendering of data in an inactive layer using light gray as color only. + * @throws IllegalArgumentException if {@code g} is null + * @throws IllegalArgumentException if {@code nc} is null + */ + public StyledTiledMapRenderer(Graphics2D g, NavigatableComponent nc, boolean isInactiveMode) { + super(g, nc, isInactiveMode); + } + + @Override + public void render(OsmData data, boolean renderVirtualNodes, Bounds bounds) { + // If there is no cache, fall back to old behavior + if (this.cache == null) { + super.render(data, renderVirtualNodes, bounds); + return; + } + final Executor worker = MainApplication.worker; + final BufferedImage tempImage; + final Graphics2D tempG2d; + // I'd like to avoid two image copies, but there are some issues using the original g2d object + tempImage = nc.getGraphicsConfiguration().createCompatibleImage(this.nc.getWidth(), this.nc.getHeight(), Transparency.TRANSLUCENT); + tempG2d = tempImage.createGraphics(); + tempG2d.setComposite(AlphaComposite.DstAtop); // Avoid tile lines in large areas + + final List toRender = TileZXY.boundsToTiles(bounds.getMinLat(), bounds.getMinLon(), + bounds.getMaxLat(), bounds.getMaxLon(), zoom).collect(Collectors.toList()); + final Bounds box = new Bounds(bounds); + toRender.stream().map(TileZXY::tileToBounds).forEach(box::extend); + final int tileSize; + if (toRender.isEmpty()) { + tileSize = Config.getPref().getInt("mappaint.fast_render.tile_size", 256); // Mostly to keep the compiler happy + } else { + final TileZXY tile = toRender.get(0); + final Bounds box2 = TileZXY.tileToBounds(tile); + final Point min = this.nc.getPoint(box2.getMin()); + final Point max = this.nc.getPoint(box2.getMax()); + tileSize = max.x - min.x + BUFFER_PIXELS; + } + + // Sort the tiles based off of proximity to the mouse pointer + if (nc instanceof MapView) { // Ideally this would either be an interface or a method in NavigableComponent + final MapView mv = (MapView) nc; + final MouseEvent mouseEvent = mv.lastMEvent; + final LatLon mousePosition = nc.getLatLon(mouseEvent.getX(), mouseEvent.getY()); + final TileZXY mouseTile = TileZXY.latLonToTile(mousePosition.lat(), mousePosition.lon(), zoom); + toRender.sort(Comparator.comparingInt(tile -> { + final int x = tile.x() - mouseTile.x(); + final int y = tile.y() - mouseTile.y(); + return x * x + y * y; + })); + } + + // We want to prioritize where the mouse is, but having some in the queue will reduce overall paint time + int submittedTile = 5; + int painted = 0; + for (TileZXY tile : toRender) { + final Image tileImage; + // Needed to avoid having tiles that aren't rendered properly + final ImageCache tImg = this.cache.get(tile); + final boolean wasDirty = tImg != null && tImg.isDirty(); + if (tImg != null && !tImg.isDirty() && tImg.imageFuture() != null) { + submittedTile = 0; // Don't submit new tiles if there are futures already in the queue. Not perfect. + } + if (submittedTile > 0 && (tImg == null || tImg.isDirty())) { + // Ensure that we don't add a large number of render calls + if (tImg != null && tImg.imageFuture() != null) { + tImg.imageFuture().cancel(); + } + submittedTile--; + // Note that the paint code is *not* thread safe, so all tiles must be painted on the same thread. + // FIXME figure out how to make this thread safe? Probably not necessary, since UI isn't blocked, but it would be a nice to have + TileLoader loader = new TileLoader(data, tile, tileSize, new ArrayList<>()); + worker.execute(loader); + if (tImg == null) { + this.cache.put(tile, new ImageCache(null, loader, false)); + } else { + // This might cause some extra renders, but *probably* ok + this.cache.put(tile, new ImageCache(tImg.image(), loader, true)); + } + tileImage = tImg != null ? tImg.image() : null; + } else if (tImg != null) { + tileImage = tImg.image(); + } else { + tileImage = null; + } + final Point point = this.nc.getPoint(tile); + if (tileImage != null) { + if ((wasDirty && Logging.isTraceEnabled()) || this.isInactiveMode) { + tempG2d.setColor(Color.DARK_GRAY); + tempG2d.fillRect(point.x, point.y, tileSize, tileSize); + } else { + painted++; + } + // There seems to be an off-by-one error somewhere. + tempG2d.drawImage(tileImage, point.x + 1, point.y + 1, null, null); + } else { + Logging.trace("StyledMapRenderer did not paint tile {1}", tile); + } + } + // Force another render pass if there may be more tiles to render + if (submittedTile <= 0) { + worker.execute(nc::invalidate); + } + final double percentDrawn = 100 * painted / (double) toRender.size(); + if (percentDrawn < 99.99) { + final int x = 0; + final int y = nc.getHeight() / 8; + final String message = tr("Rendering Status: {0}%", Math.floor(percentDrawn)); + tempG2d.setComposite(AlphaComposite.SrcOver); + tempG2d.setFont(new Font("sansserif", Font.BOLD, 13)); + tempG2d.setColor(Color.BLACK); + tempG2d.drawString(message, x + 1, y); + tempG2d.setColor(Color.LIGHT_GRAY); + tempG2d.drawString(message, x, y); + } + tempG2d.dispose(); + g.drawImage(tempImage, 0, 0, null); + } + + /** + * Set the cache for this painter. If not set, this acts like {@link StyledMapRenderer}. + * @param box The box we will be rendering -- any jobs for tiles outside of this box will be cancelled + * @param cache The cache to use + * @param zoom The zoom level to use for creating the tiles + * @param notifier The method to call when a tile has been updated. This may or may not be called in the EDT. + */ + public void setCache(Bounds box, CacheAccess cache, int zoom, Consumer notifier) { + this.cache = cache; + this.zoom = zoom; + this.notifier = notifier != null ? notifier : tile -> { /* Do nothing */ }; + + Set tiles = TileZXY.boundsToTiles(box.getMinLat(), box.getMinLon(), box.getMaxLat(), box.getMaxLon(), zoom) + .collect(Collectors.toSet()); + cache.getMatching(".*").forEach((key, value) -> { + if (!tiles.contains(key)) { + cancelImageFuture(cache, key, value); + } + }); + } + + /** + * Cancel a job for a tile + * @param cache The cache with the job + * @param key The tile key + * @param value The {@link ImageCache} to remove and cancel + */ + private static void cancelImageFuture(CacheAccess cache, TileZXY key, ImageCache value) { + if (value.imageFuture() != null) { + value.imageFuture().cancel(); + if (value.image() == null) { + cache.remove(key); + } else { + cache.put(key, new ImageCache(value.image(), null, value.isDirty())); + } + } + } + + /** + * Generate tile images + * @param data The data to generate tiles from + * @param tiles The collection of tiles to generate (note: there is currently a bug with multiple tiles) + * @param tileSize The size of the tile image + * @return The image for the tiles passed in + */ + private BufferedImage generateTiles(OsmData data, Collection tiles, int tileSize) { + if (tiles.isEmpty()) { + throw new IllegalArgumentException("tiles cannot be empty"); + } + // We need to know how large of an area we are rendering; we get the min x/y and max x/y in order to get the + // number of tiles in the x/y directions we are rendering. + final IntSummaryStatistics xStats = tiles.stream().mapToInt(TileZXY::x).distinct().summaryStatistics(); + final IntSummaryStatistics yStats = tiles.stream().mapToInt(TileZXY::y).distinct().summaryStatistics(); + final int xCount = xStats.getMax() - xStats.getMin() + 1; // inclusive + final int yCount = yStats.getMax() - yStats.getMin() + 1; // inclusive + final int width = tileSize * (2 * BUFFER_TILES + xCount); + final int height = tileSize * (2 * BUFFER_TILES + yCount); + // getWidth and getHeight are called in the constructor; Java 22 will let us call super after we set variables. + final NavigatableComponent temporaryView = new NavigatableComponent() { + @Override + public int getWidth() { + return width; + } + + @Override + public int getHeight() { + return height; + } + }; + // These bounds are used to set the render area; it includes the buffer area. + final Bounds bounds = generateRenderArea(tiles); + + temporaryView.zoomTo(bounds.getCenter().getEastNorth(ProjectionRegistry.getProjection()), mapState.getScale()); + BufferedImage bufferedImage = Optional.ofNullable(nc.getGraphicsConfiguration()) + .map(gc -> gc.createCompatibleImage(tileSize * xCount + xCount, tileSize * yCount + xCount, Transparency.TRANSLUCENT)) + .orElseGet(() -> new BufferedImage(tileSize * xCount + xCount, tileSize * yCount + xCount, BufferedImage.TYPE_INT_ARGB)); + Graphics2D g2d = bufferedImage.createGraphics(); + try { + g2d.setRenderingHints(Map.of(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON)); + g2d.setTransform(AffineTransform.getTranslateInstance(-BUFFER_TILES * (double) tileSize, -BUFFER_TILES * (double) tileSize)); + final AbstractMapRenderer tilePainter = MapRendererFactory.getInstance().createActiveRenderer(g2d, temporaryView, false); + tilePainter.render(data, true, bounds); + } finally { + g2d.dispose(); + } + return bufferedImage; + } + + /** + * Generate the area for rendering + * @param tiles The tiles that we want to render + * @return The generated render area with {@link #BUFFER_TILES} on all sides. + */ + private static Bounds generateRenderArea(Collection tiles) { + Bounds bounds = null; + for (TileZXY tile : tiles) { + if (bounds == null) { + bounds = TileZXY.tileToBounds(tile); + } + bounds.extend(TileZXY.tileToBounds(new TileZXY(tile.zoom(), tile.x() - BUFFER_TILES, tile.y() - BUFFER_TILES))); + bounds.extend(TileZXY.tileToBounds(new TileZXY(tile.zoom(), tile.x() + BUFFER_TILES, tile.y() + BUFFER_TILES))); + } + return Objects.requireNonNull(bounds); + } + + /** + * A loader for tiles + */ + class TileLoader implements Runnable { + private final TileZXY tile; + private final int tileSize; + private final OsmData data; + private boolean cancel; + private final Collection tileCollection; + private boolean done; + + /** + * Create a new tile loader + * @param data The data to use for painting + * @param tile The tile this tile loader is for + * @param tileSize The expected size of this tile + * @param tileCollection The collection of tiles that this tile is being rendered with (for batching) + */ + TileLoader(OsmData data, TileZXY tile, int tileSize, Collection tileCollection) { + this.data = data; + this.tile = tile; + this.tileSize = tileSize; + this.tileCollection = tileCollection; + this.tileCollection.add(this); + } + + @Override + public void run() { + if (!cancel) { + synchronized (tileCollection) { + if (!done) { + final BufferedImage tImage = generateTiles(data, + tileCollection.stream().map(t -> t.tile).collect(Collectors.toList()), tileSize); + final int minX = tileCollection.stream().map(t -> t.tile).mapToInt(TileZXY::x).min().orElse(this.tile.x()); + final int minY = tileCollection.stream().map(t -> t.tile).mapToInt(TileZXY::y).min().orElse(this.tile.y()); + for (TileLoader loader : tileCollection) { + final TileZXY txy = loader.tile; + final int x = (txy.x() - minX) * (tileSize - BUFFER_PIXELS) + BUFFER_PIXELS / 2; + final int y = (txy.y() - minY) * (tileSize - BUFFER_PIXELS) + BUFFER_PIXELS / 2; + final int wh = tileSize - BUFFER_PIXELS / 2; + + final BufferedImage tileImage = tImage.getSubimage(x, y, wh, wh); + loader.cacheTile(tileImage); + } + } + } + } + } + + /** + * Finish a tile generation job + * @param tImage The tile image for this job + */ + private synchronized void cacheTile(BufferedImage tImage) { + cache.put(tile, new ImageCache(tImage, null, false)); + done = true; + notifier.accept(tile); + } + + /** + * Cancel this job without causing a {@link java.util.concurrent.CancellationException} + */ + void cancel() { + this.cancel = true; + } + } +} diff --git a/src/org/openstreetmap/josm/data/osm/visitor/paint/TileZXY.java b/src/org/openstreetmap/josm/data/osm/visitor/paint/TileZXY.java new file mode 100644 index 00000000000..e6e73b52b06 --- /dev/null +++ b/src/org/openstreetmap/josm/data/osm/visitor/paint/TileZXY.java @@ -0,0 +1,169 @@ +// License: GPL. For details, see LICENSE file. +package org.openstreetmap.josm.data.osm.visitor.paint; + +import java.util.stream.IntStream; +import java.util.stream.Stream; + +import org.openstreetmap.josm.data.Bounds; +import org.openstreetmap.josm.data.coor.ILatLon; + +/** + * A record used for storing tile information for painting + * @since 19176 + */ +public final class TileZXY implements ILatLon { + private final int zoom; + private final int x; + private final int y; + + /** + * Create a new {@link TileZXY} object + * @param zoom The zoom for which this tile was created + * @param x The x coordinate at the specified zoom level + * @param y The y coordinate at the specified zoom level + */ + public TileZXY(int zoom, int x, int y) { + this.zoom = zoom; + this.x = x; + this.y = y; + } + + /** + * Get the zoom level + * @return The zoom level for which this tile was created + */ + public int zoom() { + return this.zoom; + } + + /** + * Get the x coordinate + * @return The x coordinate for this tile + */ + public int x() { + return this.x; + } + + /** + * Get the y coordinate + * @return The y coordinate for this tile + */ + public int y() { + return this.y; + } + + /** + * Get the latitude for upper-left corner of this tile + * @return The latitude + */ + @Override + public double lat() { + return yToLat(this.y(), this.zoom()); + } + + /** + * Get the longitude for the upper-left corner of this tile + * @return The longitude + */ + @Override + public double lon() { + return xToLon(this.x(), this.zoom()); + } + + /** + * Convert a bounds to a series of tiles that entirely cover the bounds + * @param minLat The minimum latitude + * @param minLon The minimum longitude + * @param maxLat The maximum latitude + * @param maxLon The maximum longitude + * @param zoom The zoom level to generate the tiles for + * @return The stream of tiles + */ + public static Stream boundsToTiles(double minLat, double minLon, double maxLat, double maxLon, int zoom) { + return boundsToTiles(minLat, minLon, maxLat, maxLon, zoom, 0); + } + + /** + * Convert a bounds to a series of tiles that entirely cover the bounds + * @param minLat The minimum latitude + * @param minLon The minimum longitude + * @param maxLat The maximum latitude + * @param maxLon The maximum longitude + * @param zoom The zoom level to generate the tiles for + * @param expansion The number of tiles to expand on the x/y axis (1 row north, 1 row south, 1 column left, 1 column right) + * @return The stream of tiles + */ + public static Stream boundsToTiles(double minLat, double minLon, double maxLat, double maxLon, int zoom, int expansion) { + final TileZXY upperRight = latLonToTile(maxLat, maxLon, zoom); + final TileZXY lowerLeft = latLonToTile(minLat, minLon, zoom); + return IntStream.rangeClosed(lowerLeft.x() - expansion, upperRight.x() + expansion) + .mapToObj(x -> IntStream.rangeClosed(upperRight.y() - expansion, lowerLeft.y() + expansion) + .mapToObj(y -> new TileZXY(zoom, x, y))) + .flatMap(stream -> stream); + } + + /** + * Convert a tile to the bounds for that tile + * @param tile The tile to get the bounds for + * @return The bounds + */ + public static Bounds tileToBounds(TileZXY tile) { + return new Bounds(yToLat(tile.y() + 1, tile.zoom()), xToLon(tile.x(), tile.zoom()), + yToLat(tile.y(), tile.zoom()), xToLon(tile.x() + 1, tile.zoom())); + } + + /** + * Convert a x tile coordinate to a latitude + * @param x The x coordinate + * @param zoom The zoom level to use for the calculation + * @return The latitude for the x coordinate (upper-left of the tile) + */ + public static double xToLon(int x, int zoom) { + return (x / Math.pow(2, zoom)) * 360 - 180; + } + + /** + * Convert a y tile coordinate to a latitude + * @param y The y coordinate + * @param zoom The zoom level to use for the calculation + * @return The latitude for the y coordinate (upper-left of the tile) + */ + public static double yToLat(int y, int zoom) { + double t = Math.PI - (2 * Math.PI * y) / Math.pow(2, zoom); + return 180 / Math.PI * Math.atan((Math.exp(t) - Math.exp(-t)) / 2); + } + + /** + * Convert a lat, lon, and zoom to a tile coordiante + * @param lat The latitude + * @param lon The longitude + * @param zoom The zoom level + * @return The specified tile coordinates at the specified zoom + */ + public static TileZXY latLonToTile(double lat, double lon, int zoom) { + int xCoord = (int) Math.floor(Math.pow(2, zoom) * (180 + lon) / 360); + int yCoord = (int) Math.floor(Math.pow(2, zoom) * + (1 - Math.log(Math.tan(Math.toRadians(lat)) + 1 / Math.cos(Math.toRadians(lat))) / Math.PI) / 2); + return new TileZXY(zoom, xCoord, yCoord); + } + + @Override + public String toString() { + return "TileZXY{" + zoom + "/" + x + "/" + y + "}"; + } + + @Override + public int hashCode() { + // We only care about comparing zoom, x, and y + return Integer.hashCode(this.zoom) + 31 * (Integer.hashCode(this.x) + 31 * Integer.hashCode(this.y)); + } + + @Override + public boolean equals(Object obj) { + if (obj instanceof TileZXY) { + TileZXY o = (TileZXY) obj; + return this.zoom == o.zoom && this.x == o.x && this.y == o.y; + } + return false; + } +} diff --git a/src/org/openstreetmap/josm/gui/MainMenu.java b/src/org/openstreetmap/josm/gui/MainMenu.java index 6baa0a1e54b..e8fbe10c847 100644 --- a/src/org/openstreetmap/josm/gui/MainMenu.java +++ b/src/org/openstreetmap/josm/gui/MainMenu.java @@ -103,6 +103,7 @@ import org.openstreetmap.josm.actions.SimplifyWayAction; import org.openstreetmap.josm.actions.SplitWayAction; import org.openstreetmap.josm.actions.TaggingPresetSearchAction; +import org.openstreetmap.josm.actions.TiledRenderToggleAction; import org.openstreetmap.josm.actions.UnGlueAction; import org.openstreetmap.josm.actions.UnJoinNodeWayAction; import org.openstreetmap.josm.actions.UndoAction; @@ -248,6 +249,8 @@ public enum WINDOW_MENU_GROUP { /* View menu */ /** View / Wireframe View */ public final WireframeToggleAction wireFrameToggleAction = new WireframeToggleAction(); + /** View / Tiled Rendering */ + public final TiledRenderToggleAction tiledRenderToggleAction = new TiledRenderToggleAction(); /** View / Hatch area outside download */ public final DrawBoundariesOfDownloadedDataAction drawBoundariesOfDownloadedDataAction = new DrawBoundariesOfDownloadedDataAction(); /** View / Advanced info */ @@ -799,6 +802,12 @@ public void initialize() { viewMenu.add(wireframe); wireframe.setAccelerator(wireFrameToggleAction.getShortcut().getKeyStroke()); wireFrameToggleAction.addButtonModel(wireframe.getModel()); + // -- tiled render toggle action -- not intended to be permanently an "Expert" mode option + final JCheckBoxMenuItem tiledRender = new JCheckBoxMenuItem(tiledRenderToggleAction); + viewMenu.add(tiledRender); + tiledRenderToggleAction.addButtonModel(tiledRender.getModel()); + ExpertToggleAction.addVisibilitySwitcher(tiledRender); + // -- hatch toggle action final JCheckBoxMenuItem hatchAreaOutsideDownloadMenuItem = drawBoundariesOfDownloadedDataAction.getCheckbox(); viewMenu.add(hatchAreaOutsideDownloadMenuItem); ExpertToggleAction.addVisibilitySwitcher(hatchAreaOutsideDownloadMenuItem); diff --git a/src/org/openstreetmap/josm/gui/MapView.java b/src/org/openstreetmap/josm/gui/MapView.java index 6d9f17df793..5605881848f 100644 --- a/src/org/openstreetmap/josm/gui/MapView.java +++ b/src/org/openstreetmap/josm/gui/MapView.java @@ -6,13 +6,16 @@ import java.awt.AlphaComposite; import java.awt.BasicStroke; import java.awt.Color; +import java.awt.Component; import java.awt.Dimension; import java.awt.Graphics; import java.awt.Graphics2D; +import java.awt.GraphicsEnvironment; import java.awt.Point; import java.awt.Rectangle; import java.awt.Shape; import java.awt.Stroke; +import java.awt.Transparency; import java.awt.event.ComponentAdapter; import java.awt.event.ComponentEvent; import java.awt.event.KeyEvent; @@ -330,6 +333,13 @@ public static List getMapNavigationComponents(MapView forM return Arrays.asList(zoomSlider, scaler); } + private static BufferedImage getAcceleratedImage(Component mv, int width, int height) { + if (GraphicsEnvironment.isHeadless()) { + return new BufferedImage(width, height, BufferedImage.TYPE_3BYTE_BGR); + } + return mv.getGraphicsConfiguration().createCompatibleImage(width, height, Transparency.OPAQUE); + } + // remebered geometry of the component private Dimension oldSize; private Point oldLoc; @@ -548,13 +558,13 @@ private void drawMapContent(Graphics2D g) { && nonChangedLayers.equals(visibleLayers.subList(0, nonChangedLayers.size())); if (null == offscreenBuffer || offscreenBuffer.getWidth() != width || offscreenBuffer.getHeight() != height) { - offscreenBuffer = new BufferedImage(width, height, BufferedImage.TYPE_3BYTE_BGR); + offscreenBuffer = getAcceleratedImage(this, width, height); } if (!canUseBuffer || nonChangedLayersBuffer == null) { if (null == nonChangedLayersBuffer || nonChangedLayersBuffer.getWidth() != width || nonChangedLayersBuffer.getHeight() != height) { - nonChangedLayersBuffer = new BufferedImage(width, height, BufferedImage.TYPE_3BYTE_BGR); + nonChangedLayersBuffer = getAcceleratedImage(this, width, height); } Graphics2D g2 = nonChangedLayersBuffer.createGraphics(); g2.setClip(scaledClip); diff --git a/src/org/openstreetmap/josm/gui/layer/OsmDataLayer.java b/src/org/openstreetmap/josm/gui/layer/OsmDataLayer.java index 14f71ddc872..56de4532133 100644 --- a/src/org/openstreetmap/josm/gui/layer/OsmDataLayer.java +++ b/src/org/openstreetmap/josm/gui/layer/OsmDataLayer.java @@ -49,6 +49,8 @@ import javax.swing.JPanel; import javax.swing.JScrollPane; +import org.apache.commons.jcs3.access.CacheAccess; +import org.openstreetmap.gui.jmapviewer.OsmMercator; import org.openstreetmap.josm.actions.AutoScaleAction; import org.openstreetmap.josm.actions.ExpertToggleAction; import org.openstreetmap.josm.actions.RenameLayerAction; @@ -56,8 +58,10 @@ import org.openstreetmap.josm.data.APIDataSet; import org.openstreetmap.josm.data.Bounds; import org.openstreetmap.josm.data.Data; +import org.openstreetmap.josm.data.IBounds; import org.openstreetmap.josm.data.ProjectionBounds; import org.openstreetmap.josm.data.UndoRedoHandler; +import org.openstreetmap.josm.data.cache.JCSCacheManager; import org.openstreetmap.josm.data.conflict.Conflict; import org.openstreetmap.josm.data.conflict.ConflictCollection; import org.openstreetmap.josm.data.coor.EastNorth; @@ -70,6 +74,7 @@ import org.openstreetmap.josm.data.gpx.GpxTrackSegment; import org.openstreetmap.josm.data.gpx.IGpxTrackSegment; import org.openstreetmap.josm.data.gpx.WayPoint; +import org.openstreetmap.josm.data.osm.BBox; import org.openstreetmap.josm.data.osm.DataIntegrityProblemException; import org.openstreetmap.josm.data.osm.DataSelectionListener; import org.openstreetmap.josm.data.osm.DataSet; @@ -77,7 +82,10 @@ import org.openstreetmap.josm.data.osm.DatasetConsistencyTest; import org.openstreetmap.josm.data.osm.DownloadPolicy; import org.openstreetmap.josm.data.osm.HighlightUpdateListener; +import org.openstreetmap.josm.data.osm.INode; import org.openstreetmap.josm.data.osm.IPrimitive; +import org.openstreetmap.josm.data.osm.IRelation; +import org.openstreetmap.josm.data.osm.IWay; import org.openstreetmap.josm.data.osm.Node; import org.openstreetmap.josm.data.osm.OsmPrimitive; import org.openstreetmap.josm.data.osm.OsmPrimitiveComparator; @@ -91,7 +99,10 @@ import org.openstreetmap.josm.data.osm.visitor.BoundingXYVisitor; import org.openstreetmap.josm.data.osm.visitor.OsmPrimitiveVisitor; import org.openstreetmap.josm.data.osm.visitor.paint.AbstractMapRenderer; +import org.openstreetmap.josm.data.osm.visitor.paint.ImageCache; import org.openstreetmap.josm.data.osm.visitor.paint.MapRendererFactory; +import org.openstreetmap.josm.data.osm.visitor.paint.StyledTiledMapRenderer; +import org.openstreetmap.josm.data.osm.visitor.paint.TileZXY; import org.openstreetmap.josm.data.osm.visitor.paint.relations.MultipolygonCache; import org.openstreetmap.josm.data.preferences.BooleanProperty; import org.openstreetmap.josm.data.preferences.IntegerProperty; @@ -104,6 +115,8 @@ import org.openstreetmap.josm.gui.MapFrame; import org.openstreetmap.josm.gui.MapView; import org.openstreetmap.josm.gui.MapViewState.MapViewPoint; +import org.openstreetmap.josm.gui.NavigatableComponent; +import org.openstreetmap.josm.gui.PrimitiveHoverListener; import org.openstreetmap.josm.gui.datatransfer.ClipboardUtils; import org.openstreetmap.josm.gui.datatransfer.data.OsmLayerTransferData; import org.openstreetmap.josm.gui.dialogs.LayerListDialog; @@ -144,7 +157,10 @@ * @author imi * @since 17 */ -public class OsmDataLayer extends AbstractOsmDataLayer implements Listener, DataSelectionListener, HighlightUpdateListener { +public class OsmDataLayer extends AbstractOsmDataLayer + implements Listener, DataSelectionListener, HighlightUpdateListener, PrimitiveHoverListener { + private static final int MAX_ZOOM = 30; + private static final int OVER_ZOOM = 2; private static final int HATCHED_SIZE = 15; // U+2205 EMPTY SET private static final String IS_EMPTY_SYMBOL = "\u2205"; @@ -155,6 +171,15 @@ public class OsmDataLayer extends AbstractOsmDataLayer implements Listener, Data private boolean requiresUploadToServer; /** Flag used to know if the layer is being uploaded */ private final AtomicBoolean isUploadInProgress = new AtomicBoolean(false); + /** + * A cache used for painting + */ + private final CacheAccess cache = JCSCacheManager.getCache("osmDataLayer:" + System.identityHashCode(this)); + /** The map paint index that was painted (used to invalidate {@link #cache}) */ + private int lastDataIdx; + /** The last zoom level (we invalidate all tiles when switching layers) */ + private int lastZoom; + private boolean hoverListenerAdded; /** * List of validation errors in this layer. @@ -497,13 +522,21 @@ public Icon getIcon() { * Draw nodes last to overlap the ways they belong to. */ @Override public void paint(final Graphics2D g, final MapView mv, Bounds box) { + if (!hoverListenerAdded) { + MainApplication.getMap().mapView.addPrimitiveHoverListener(this); + hoverListenerAdded = true; + } boolean active = mv.getLayerManager().getActiveLayer() == this; boolean inactive = !active && Config.getPref().getBoolean("draw.data.inactive_color", true); boolean virtual = !inactive && mv.isVirtualNodesEnabled(); + paintHatch(g, mv, active); + paintData(g, mv, box, inactive, virtual); + } + private void paintHatch(final Graphics2D g, final MapView mv, boolean active) { // draw the hatched area for non-downloaded region. only draw if we're the active // and bounds are defined; don't draw for inactive layers or loaded GPX files etc - if (active && DrawingPreference.SOURCE_BOUNDS_PROP.get() && !data.getDataSources().isEmpty()) { + if (active && Boolean.TRUE.equals(DrawingPreference.SOURCE_BOUNDS_PROP.get()) && !data.getDataSources().isEmpty()) { // initialize area with current viewport Rectangle b = mv.getBounds(); // on some platforms viewport bounds seem to be offset from the left, @@ -536,11 +569,43 @@ public Icon getIcon() { Logging.error(e); } } + } + private void paintData(final Graphics2D g, final MapView mv, Bounds box, boolean inactive, boolean virtual) { + // Used to invalidate cache + int zoom = getZoom(mv); + if (zoom != lastZoom) { + // We just mark the previous zoom as dirty before moving in. + // It means we don't have to traverse up/down z-levels marking tiles as dirty (this can get *very* expensive). + this.cache.getMatching("TileZXY\\{" + lastZoom + "/.*") + .forEach((tile, imageCache) -> this.cache.put(tile, imageCache.becomeDirty())); + } + lastZoom = zoom; AbstractMapRenderer painter = MapRendererFactory.getInstance().createActiveRenderer(g, mv, inactive); - painter.enableSlowOperations(mv.getMapMover() == null || !mv.getMapMover().movementInProgress() - || !PROPERTY_HIDE_LABELS_WHILE_DRAGGING.get()); - painter.render(data, virtual, box); + if (!(painter instanceof StyledTiledMapRenderer) || zoom - OVER_ZOOM > Config.getPref().getInt("mappaint.fast_render.zlevel", 16)) { + painter.enableSlowOperations(mv.getMapMover() == null || !mv.getMapMover().movementInProgress() + || !PROPERTY_HIDE_LABELS_WHILE_DRAGGING.get()); + } else { + StyledTiledMapRenderer renderer = (StyledTiledMapRenderer) painter; + renderer.setCache(box, this.cache, zoom, (tile) -> { + /* This causes "bouncing". I'm not certain why. + if (oldState.equalsInWindow(mv.getState())) { (oldstate = mv.getState()) + final Point upperLeft = mv.getPoint(tile); + final Point lowerRight = mv.getPoint(new TileZXY(tile.zoom(), tile.x() + 1, tile.y() + 1)); + GuiHelper.runInEDT(() -> mv.repaint(0, upperLeft.x, upperLeft.y, lowerRight.x - upperLeft.x, lowerRight.y - upperLeft.y)); + } + */ + // Invalidate doesn't trigger an instant repaint, but putting this off lets us batch the repaints needed for multiple tiles + MainApplication.worker.submit(this::invalidate); + }); + + if (this.data.getMappaintCacheIndex() != this.lastDataIdx) { + this.cache.clear(); + this.lastDataIdx = this.data.getMappaintCacheIndex(); + Logging.trace("OsmDataLayer {0} paint cache cleared", this.getName()); + } + } + painter.render(this.data, virtual, box); MainApplication.getMap().conflictDialog.paintConflicts(g, mv); } @@ -1147,6 +1212,10 @@ public synchronized void destroy() { validationErrors.clear(); removeClipboardDataFor(this); recentRelations.clear(); + if (hoverListenerAdded) { + hoverListenerAdded = false; + MainApplication.getMap().mapView.removePrimitiveHoverListener(this); + } } protected static void removeClipboardDataFor(OsmDataLayer osm) { @@ -1165,6 +1234,7 @@ protected static void removeClipboardDataFor(OsmDataLayer osm) { @Override public void processDatasetEvent(AbstractDatasetChangedEvent event) { + resetTiles(event.getPrimitives()); invalidate(); setRequiresSaveToFile(true); setRequiresUploadToServer(event.getDataset().requiresUploadToServer()); @@ -1172,9 +1242,119 @@ public void processDatasetEvent(AbstractDatasetChangedEvent event) { @Override public void selectionChanged(SelectionChangeEvent event) { + Set primitives = new HashSet<>(event.getAdded()); + primitives.addAll(event.getRemoved()); + resetTiles(primitives); invalidate(); } + private void resetTiles(Collection primitives) { + if (primitives.size() >= this.data.allNonDeletedCompletePrimitives().size() || primitives.size() > 100) { + dirtyAll(); + return; + } + if (primitives.size() < 5) { + for (IPrimitive p : primitives) { + resetTiles(p); + } + return; + } + // Most of the time, a selection is going to be a big box. + // So we want to optimize for that case. + BBox box = null; + for (IPrimitive primitive : primitives) { + if (primitive == null || primitive.getDataSet() != this.getDataSet()) continue; + final Collection referrers = primitive.getReferrers(); + if (box == null) { + box = new BBox(primitive.getBBox()); + } else { + box.addPrimitive(primitive, 0); + } + for (IPrimitive referrer : referrers) { + box.addPrimitive(referrer, 0); + } + } + if (box != null) { + resetBounds(box.getMinLat(), box.getMinLon(), box.getMaxLat(), box.getMaxLon()); + } + } + + private void resetTiles(IPrimitive p) { + if (p instanceof INode) { + resetBounds(getInvalidatedBBox((INode) p, null)); + } else if (p instanceof IWay) { + IWay way = (IWay) p; + for (int i = 0; i < way.getNodesCount() - 1; i++) { + resetBounds(getInvalidatedBBox(way.getNode(i), way.getNode(i + 1))); + } + } else if (p instanceof IRelation) { + for (IPrimitive member : ((IRelation) p).getMemberPrimitivesList()) { + resetTiles(member); + } + } else { + throw new IllegalArgumentException("Unsupported primitive type: " + p.getClass().getName()); + } + } + + private BBox getInvalidatedBBox(INode first, INode second) { + final BBox bbox = new BBox(first); + if (second != null) { + bbox.add(second); + } + return bbox; + } + + private void resetBounds(IBounds bbox) { + resetBounds(bbox.getMinLat(), bbox.getMinLon(), bbox.getMaxLat(), bbox.getMaxLon()); + } + + private void resetBounds(double minLat, double minLon, double maxLat, double maxLon) { + // Get the current zoom. Hopefully we aren't painting with a different navigatable component + final int currentZoom = lastZoom; + final AtomicInteger counter = new AtomicInteger(); + TileZXY.boundsToTiles(minLat, minLon, maxLat, maxLon, currentZoom, 1).limit(100).forEach(tile -> { + final ImageCache imageCache = this.cache.get(tile); + if (imageCache != null && !imageCache.isDirty()) { + this.cache.put(tile, imageCache.becomeDirty()); + } + counter.incrementAndGet(); + }); + if (counter.get() > 100) { + dirtyAll(); + } + } + + private void dirtyAll() { + this.cache.getMatching(".*").forEach((key, value) -> { + this.cache.remove(key); + this.cache.put(key, value.becomeDirty()); + }); + } + + /** + * Get the zoom for a {@link NavigatableComponent} + * @param navigatableComponent The component to get the zoom from + * @return The zoom for the navigatable component + */ + private static int getZoom(NavigatableComponent navigatableComponent) { + final double scale = navigatableComponent.getScale(); + // We might have to fall back to the old method if user is reprojecting + // 256 is the "target" size, (TODO check HiDPI!) + final int targetSize = Config.getPref().getInt("mappaint.fast_render.tile_size", 256); + final double topResolution = 2 * Math.PI * OsmMercator.EARTH_RADIUS / targetSize; + int zoom; + for (zoom = 0; zoom < MAX_ZOOM; zoom++) { // Use something like imagery.{generic|tms}.max_zoom_lvl (20 is a bit too low for our needs) + if (scale > topResolution / Math.pow(2, zoom)) { + zoom = zoom > 0 ? zoom - 1 : zoom; + break; + } + } + // We paint at a few levels higher, note that the tiles are appropriately sized (if 256 is the "target" size, the tiles should be + // 64px square). + zoom += OVER_ZOOM; + return zoom; + } + @Override public void projectionChanged(Projection oldValue, Projection newValue) { // No reprojection required. The dataset itself is registered as projection @@ -1308,6 +1488,16 @@ public void highlightUpdated(HighlightUpdateEvent e) { invalidate(); } + @Override + public void primitiveHovered(PrimitiveHoverEvent e) { + List primitives = new ArrayList<>(2); + primitives.add(e.getHoveredPrimitive()); + primitives.add(e.getPreviousPrimitive()); + primitives.removeIf(Objects::isNull); + resetTiles(primitives); + this.invalidate(); + } + @Override public void setName(String name) { if (data != null) {