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) {