Subject: [PATCH] #11487: Try to improve render performance
---
Index: src/org/openstreetmap/josm/data/osm/visitor/paint/ImageCache.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
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
--- /dev/null	(date 1683233157310)
+++ b/src/org/openstreetmap/josm/data/osm/visitor/paint/ImageCache.java	(date 1683233157310)
@@ -0,0 +1,68 @@
+// License: GPL. For details, see LICENSE file.
+package org.openstreetmap.josm.data.osm.visitor.paint;
+
+import java.awt.Image;
+
+import javax.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 xxx
+ */
+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
+     */
+    public 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
+    public StyledTiledMapRenderer.TileLoader imageFuture() {
+        return this.imageFuture;
+    }
+}
Index: src/org/openstreetmap/josm/data/osm/visitor/paint/MapRendererFactory.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/org/openstreetmap/josm/data/osm/visitor/paint/MapRendererFactory.java b/src/org/openstreetmap/josm/data/osm/visitor/paint/MapRendererFactory.java
--- a/src/org/openstreetmap/josm/data/osm/visitor/paint/MapRendererFactory.java	(revision 18721)
+++ b/src/org/openstreetmap/josm/data/osm/visitor/paint/MapRendererFactory.java	(date 1683129681906)
@@ -191,6 +191,11 @@
                 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.")
+        );
     }
 
     /**
Index: src/org/openstreetmap/josm/data/osm/visitor/paint/StyledTiledMapRenderer.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
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
--- /dev/null	(date 1683234073125)
+++ b/src/org/openstreetmap/josm/data/osm/visitor/paint/StyledTiledMapRenderer.java	(date 1683234073125)
@@ -0,0 +1,252 @@
+// 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.Transparency;
+import java.awt.event.MouseEvent;
+import java.awt.geom.AffineTransform;
+import java.awt.image.BufferedImage;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Set;
+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
+ */
+public class StyledTiledMapRenderer extends StyledMapRenderer {
+    private CacheAccess<TileZXY, ImageCache> cache;
+    private int zoom;
+    private Consumer<TileZXY> 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 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<TileZXY> 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 + 2; // Buffer by 2 pixels to avoid black lines
+        }
+
+        // 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);
+                MainApplication.worker.submit(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) {
+                // FIXME move to isTraceEnabled prior to commit
+                if ((wasDirty && Logging.isTraceEnabled()) || this.isInactiveMode) {
+                    tempG2d.setColor(Color.DARK_GRAY);
+                    tempG2d.fillRect(point.x, point.y, tileSize, tileSize);
+                } else {
+                    painted++;
+                }
+                tempG2d.drawImage(tileImage, point.x, point.y, tileSize, tileSize, null, null);
+            } else {
+                Logging.trace("StyledMapRenderer did not paint tile {1}", tile);
+            }
+        }
+        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<TileZXY, ImageCache> cache, int zoom, Consumer<TileZXY> notifier) {
+        this.cache = cache;
+        this.zoom = zoom;
+        this.notifier = notifier != null ? notifier : tile -> { /* Do nothing */ };
+
+        Set<TileZXY> 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);
+            }
+        });
+    }
+
+    private static void cancelImageFuture(CacheAccess<TileZXY, ImageCache> 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()));
+            }
+        }
+    }
+
+    private BufferedImage generateTile(OsmData<?, ?, ?, ?> data, TileZXY tile, int tileSize) {
+        BufferedImage bufferedImage = nc.getGraphicsConfiguration().createCompatibleImage(tileSize, tileSize, Transparency.TRANSLUCENT);
+        Graphics2D g2d = bufferedImage.createGraphics();
+        try {
+            // Render to the surrounding tiles for continuity -- this probably needs to be tweaked
+            final int buffer = 2;
+            Bounds bounds = TileZXY.tileToBounds(new TileZXY(zoom, tile.x() - buffer, tile.y() - buffer));
+            bounds.extend(TileZXY.tileToBounds(new TileZXY(zoom, tile.x() + buffer, tile.y() + buffer)));
+
+            final int renderSize = tileSize * (2 * buffer + 1);
+            final NavigatableComponent temporaryView = new NavigatableComponent() {
+                @Override
+                public int getWidth() {
+                    return renderSize;
+                }
+
+                @Override
+                public int getHeight() {
+                    return renderSize;
+                }
+            };
+            temporaryView.setSize(renderSize, renderSize);
+            temporaryView.zoomTo(TileZXY.tileToBounds(tile).getCenter().getEastNorth(ProjectionRegistry.getProjection()), mapState.getScale());
+            g2d.setTransform(AffineTransform.getTranslateInstance(-buffer * (double) tileSize, -buffer * (double) tileSize));
+            final AbstractMapRenderer tilePainter = MapRendererFactory.getInstance().createActiveRenderer(g2d, temporaryView, false);
+            tilePainter.render(data, true, bounds);
+        } finally {
+            g2d.dispose();
+        }
+        return bufferedImage;
+    }
+
+    class TileLoader implements Runnable {
+        private final TileZXY tile;
+        private final int tileSize;
+        private final OsmData<?, ?, ?, ?> data;
+        private boolean cancel;
+
+        TileLoader(OsmData<?, ?, ?, ?> data, TileZXY tile, int tileSize) {
+            this.data = data;
+            this.tile = tile;
+            this.tileSize = tileSize;
+        }
+
+        @Override
+        public void run() {
+            if (!cancel) {
+                final BufferedImage tImage = generateTile(data, tile, tileSize);
+                cache.put(tile, new ImageCache(tImage, null, false));
+                notifier.accept(tile);
+            }
+        }
+
+        /**
+         * Cancel this job without causing a {@link java.util.concurrent.CancellationException}
+         */
+        void cancel() {
+            this.cancel = true;
+        }
+    }
+}
Index: src/org/openstreetmap/josm/data/osm/visitor/paint/TileZXY.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
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
--- /dev/null	(date 1683233189728)
+++ b/src/org/openstreetmap/josm/data/osm/visitor/paint/TileZXY.java	(date 1683233189728)
@@ -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 xxx
+ */
+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<TileZXY> 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<TileZXY> 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;
+    }
+}
Index: src/org/openstreetmap/josm/gui/layer/OsmDataLayer.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/org/openstreetmap/josm/gui/layer/OsmDataLayer.java b/src/org/openstreetmap/josm/gui/layer/OsmDataLayer.java
--- a/src/org/openstreetmap/josm/gui/layer/OsmDataLayer.java	(revision 18721)
+++ b/src/org/openstreetmap/josm/gui/layer/OsmDataLayer.java	(date 1683235485554)
@@ -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;
@@ -58,6 +60,7 @@
 import org.openstreetmap.josm.data.Data;
 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 +73,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 +81,9 @@
 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.IWay;
 import org.openstreetmap.josm.data.osm.Node;
 import org.openstreetmap.josm.data.osm.OsmPrimitive;
 import org.openstreetmap.josm.data.osm.OsmPrimitiveComparator;
@@ -91,7 +97,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 +113,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 +155,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 +169,15 @@
     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<TileZXY, ImageCache> 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,6 +520,10 @@
      * 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();
@@ -537,10 +564,40 @@
             }
         }
 
+        // 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.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 +1204,10 @@
         validationErrors.clear();
         removeClipboardDataFor(this);
         recentRelations.clear();
+        if (hoverListenerAdded) {
+            hoverListenerAdded = false;
+            MainApplication.getMap().mapView.removePrimitiveHoverListener(this);
+        }
     }
 
     protected static void removeClipboardDataFor(OsmDataLayer osm) {
@@ -1165,6 +1226,7 @@
 
     @Override
     public void processDatasetEvent(AbstractDatasetChangedEvent event) {
+        resetTiles(event.getPrimitives());
         invalidate();
         setRequiresSaveToFile(true);
         setRequiresUploadToServer(event.getDataset().requiresUploadToServer());
@@ -1172,9 +1234,72 @@
 
     @Override
     public void selectionChanged(SelectionChangeEvent event) {
+        Set<IPrimitive> primitives = new HashSet<>(event.getAdded());
+        primitives.addAll(event.getRemoved());
+        resetTiles(primitives);
         invalidate();
     }
 
+    private void resetTiles(Iterable<? extends IPrimitive> primitives) {
+        for (IPrimitive primitive : primitives) {
+            if (primitive == null || primitive.getDataSet() != this.getDataSet()) continue;
+            final Collection<? extends IPrimitive> referrers = primitive.getReferrers();
+            // We can usually avoid invalidating a bunch of tiles when the way is not an area or part of a multipolygon
+            if (primitive instanceof IWay<?> && !((IWay<?>) primitive).isClosed() && referrers.stream().noneMatch(IPrimitive::isMultipolygon)) {
+                double lastLat = Double.NaN;
+                double lastLon = Double.NaN;
+                for (INode n : ((IWay<?>) primitive).getNodes()) {
+                    final double lat = n.lat();
+                    final double lon = n.lon();
+                    if (!Double.isNaN(lastLat)) {
+                        resetBounds(Math.min(lat, lastLat), Math.min(lon, lastLon),
+                                Math.max(lat, lastLat), Math.max(lon, lastLon));
+                    } else { // Just in case there is a 1 node way (illegal data)
+                        resetBounds(lat, lon, lat, lon);
+                    }
+                    lastLat = lat;
+                    lastLon = lon;
+                }
+            } else {
+                final BBox box = primitive.getBBox();
+                resetBounds(box.getMinLat(), box.getMinLon(), box.getMaxLat(), box.getMaxLon());
+                if (!referrers.isEmpty()) {
+                    resetTiles(referrers);
+                }
+            }
+        }
+    }
+
+    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;
+        TileZXY.boundsToTiles(minLat, minLon, maxLat, maxLon, currentZoom, 1).forEach(tile -> {
+            final ImageCache imageCache = this.cache.get(tile);
+            if (imageCache != null && !imageCache.isDirty()) {
+                this.cache.put(tile, imageCache.becomeDirty());
+            }
+        });
+    }
+
+    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
@@ -1307,6 +1432,12 @@
         invalidate();
     }
 
+    @Override
+    public void primitiveHovered(PrimitiveHoverEvent e) {
+        resetTiles(Arrays.asList(e.getHoveredPrimitive(), e.getPreviousPrimitive()));
+        this.invalidate();
+    }
+
     @Override
     public void setName(String name) {
         if (data != null) {
Index: src/org/openstreetmap/josm/gui/MapView.java
IDEA additional info:
Subsystem: com.intellij.openapi.diff.impl.patch.CharsetEP
<+>UTF-8
===================================================================
diff --git a/src/org/openstreetmap/josm/gui/MapView.java b/src/org/openstreetmap/josm/gui/MapView.java
--- a/src/org/openstreetmap/josm/gui/MapView.java	(revision 18721)
+++ b/src/org/openstreetmap/josm/gui/MapView.java	(date 1683228633774)
@@ -9,6 +9,7 @@
 import java.awt.Point;
 import java.awt.Rectangle;
 import java.awt.Shape;
+import java.awt.Transparency;
 import java.awt.event.ComponentAdapter;
 import java.awt.event.ComponentEvent;
 import java.awt.event.KeyEvent;
@@ -540,13 +541,13 @@
                 && 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 = this.getGraphicsConfiguration().createCompatibleImage(width, height, Transparency.OPAQUE);
         }
 
         if (!canUseBuffer || nonChangedLayersBuffer == null) {
             if (null == nonChangedLayersBuffer
                     || nonChangedLayersBuffer.getWidth() != width || nonChangedLayersBuffer.getHeight() != height) {
-                nonChangedLayersBuffer = new BufferedImage(width, height, BufferedImage.TYPE_3BYTE_BGR);
+                nonChangedLayersBuffer = this.getGraphicsConfiguration().createCompatibleImage(width, height, Transparency.OPAQUE);
             }
             Graphics2D g2 = nonChangedLayersBuffer.createGraphics();
             g2.setClip(scaledClip);
