All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.pepsoft.util.swing.TiledImageViewer Maven / Gradle / Ivy

The newest version!
/*
 * To change this template, choose Tools | Templates
 * and open the template in the editor.
 */
package org.pepsoft.util.swing;

import org.pepsoft.util.IntegerAttributeKey;

import javax.swing.*;
import java.awt.*;
import java.awt.event.*;
import java.awt.geom.Rectangle2D;
import java.awt.image.BufferedImage;
import java.awt.image.ImageObserver;
import java.awt.image.VolatileImage;
import java.lang.ref.Reference;
import java.lang.ref.SoftReference;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;

import static org.pepsoft.util.GUIUtils.getUIScale;
import static org.pepsoft.util.GUIUtils.getUIScaleInt;

/**
 * A generic visual component which can display one or more layers of large or even endless tile-based images, with
 * support for scrolling and scaling the images.
 * 
 * 

The tiles are provided by {@link TileProvider tile providers}. The tiles are requested asynchronously on multiple * threads and are cached. This means that tile providers have to do no caching themselves and are free to calculate or * generate each tile on request, even if that is relatively slow. * *

The tile providers are ordered on numbered layers. The numbering does not have to be continuous, and only one tile * provider can be configured per layer. Lower numbered layers are rendered below higher numbered layers. * *

When zooming in, this viewer performs all the scaling itself. When zooming out, for tile providers which indicate * that they support zooming, the scaling is delegated to the tile providers. * *

This class does not provide scrollbars, however it can be encapsulated in a {@link TiledImageViewerContainer} * which will surround it with scrollbars, with support for the tile providers' {@link TileProvider#getExtent() extents}. * * @author pepijn */ public class TiledImageViewer extends JComponent implements TileListener, MouseListener, MouseMotionListener, ComponentListener, HierarchyListener { /** * Create a new tiled image viewer which allows panning by dragging with the * left mouse button and which paints the central crosshair. */ public TiledImageViewer() { this(true, true, 0); } /** * Create a new tiled image viewer. * * @param leftClickDrags Whether dragging with the left mouse button should pan the image. * @param paintCentre Whether the central crosshair indicating the current location should be painted. */ @SuppressWarnings("LeakingThisInConstructor") public TiledImageViewer(boolean leftClickDrags, boolean paintCentre) { this(leftClickDrags, paintCentre, 0); } /** * Create a new tiled image viewer. * * @param leftClickDrags Whether dragging with the left mouse button should pan the image. * @param paintCentre Whether the central crosshair indicating the current location should be painted. * @param tileProviderZoomCutoff The zoom level below which zooming should be delegated to the tile providers that * support it. */ @SuppressWarnings("LeakingThisInConstructor") public TiledImageViewer(boolean leftClickDrags, boolean paintCentre, int tileProviderZoomCutoff) { this.leftClickDrags = leftClickDrags; this.paintCentre = paintCentre; this.tileProviderZoomCutoff = tileProviderZoomCutoff; String maxThreads = System.getProperty("org.pepsoft.worldpainter." + ADVANCED_SETTING_MAX_TILE_RENDER_THREADS.key); if (maxThreads != null) { threads = ADVANCED_SETTING_MAX_TILE_RENDER_THREADS.toValue(maxThreads); } else { threads = Math.min(Math.max(Runtime.getRuntime().availableProcessors() - 1, 1), ADVANCED_SETTING_MAX_TILE_RENDER_THREADS.defaultValue); } addMouseListener(this); addMouseMotionListener(this); addComponentListener(this); addHierarchyListener(this); setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); setOpaque(true); } /** * Get an unmodifiable view of the currently configured list of tile * providers. * * @return An unmodifyable view of the currently configured list of tile * providers. */ public Collection getTileProviders() { return Collections.unmodifiableCollection(tileProviders.values()); } /** * Get the number of currently configured tile providers. * * @return The number of currently configured tile providers. */ public int getTileProviderCount() { return tileProviders.size(); } /** * Set or replace a tile provider on layer 0 and reuse the existing provider's cached tile images as stale tile * images for the new provider. Mainly meant as a convenience method for clients that will only ever use one tile * provider at a time. * * @param tileProvider The tile provider to place at the specified layer. */ public void setTileProvider(TileProvider tileProvider) { setTileProvider(0, tileProvider); } /** * Set or replace a tile provider on a specific layer and reuse the existing provider's cached tile images as stale * tile images for the new provider. * * @param layer The layer at which to place the tile provider. * @param tileProvider The tile provider to place at the specified layer. */ public void setTileProvider(int layer, TileProvider tileProvider) { synchronized (TILE_CACHE_LOCK) { final TileProvider oldTileProvider = tileProviders.remove(layer); Integer zoom = null; Map> dirtyTileCache = new HashMap<>(); if (oldTileProvider != null) { zoom = tileProviderZoom.remove(oldTileProvider); oldTileProvider.removeTileListener(this); dirtyTileCache = dirtyTileCaches.remove(oldTileProvider); Map> tileCache = tileCaches.remove(oldTileProvider); // Add all live tile images from the tile cache to the dirty tile cache, for use as dirty tile for the // new tile provider for (Map.Entry> entry: tileCache.entrySet()) { Reference tileImageRef = entry.getValue(); if (tileImageRef != RENDERING) { Image tileImage = tileImageRef.get(); if (tileImage != null) { dirtyTileCache.put(entry.getKey(), tileImageRef); } } } // We're not completely sure how, but sometimes we reach here without the renderers having been started, // so check whether there actually is a queue if (queue != null) { // Prune the queue of jobs related to this tile provider for (Iterator i = queue.iterator(); i.hasNext(); ) { if (((TileRenderJob) i.next()).tileProvider == oldTileProvider) { i.remove(); } } } } if (tileProvider.isZoomSupported()) { if (zoom != null) { tileProvider.setZoom(((this.zoom + zoom) <= tileProviderZoomCutoff) ? (this.zoom + zoom) : tileProviderZoomCutoff); } else { tileProvider.setZoom((this.zoom <= tileProviderZoomCutoff) ? this.zoom : tileProviderZoomCutoff); } } tileProvider.addTileListener(this); tileProviders.put(layer, tileProvider); if (zoom != null) { tileProviderZoom.put(tileProvider, zoom); } tileCaches.put(tileProvider, new HashMap<>()); dirtyTileCaches.put(tileProvider, dirtyTileCache); // We're not completely sure how, but sometimes we reach here without the renderers having been started, so // start them now (if we're visible of course) startRenderersIfApplicable(); } fireViewChangedEvent(); repaint(); } /** * Remove a tile provider. * * @param layer The layer on the tile provider to remove is placed. */ public void removeTileProvider(int layer) { boolean providerRemoved = false; synchronized (TILE_CACHE_LOCK) { final TileProvider tileProvider = tileProviders.remove(layer); if (tileProvider != null) { tileProviderZoom.remove(tileProvider); tileProvider.removeTileListener(this); tileCaches.remove(tileProvider); dirtyTileCaches.remove(tileProvider); // We're not completely sure how, but sometimes we reach here // without the renderers having been started, so check whether there // actually is a queue if (queue != null) { // Prune the queue of jobs related to this tile provider for (Iterator i = queue.iterator(); i.hasNext(); ) { if (((TileRenderJob) i.next()).tileProvider == tileProvider) { i.remove(); } } } providerRemoved = true; } } if (providerRemoved) { fireViewChangedEvent(); repaint(); } } /** * Remove all tile providers. */ public void removeAllTileProviders() { synchronized (TILE_CACHE_LOCK) { for (TileProvider tileProvider: tileProviders.values()) { tileProvider.removeTileListener(this); } tileProviders.clear(); tileProviderZoom.clear(); if (queue != null) { queue.clear(); } tileCaches.clear(); dirtyTileCaches.clear(); } fireViewChangedEvent(); repaint(); } /** * Get the X coordinate in image coordinates of the centre of the view. * * @return The X coordinate in image coordinates of the centre of the view. */ public int getViewX() { if (zoom == 0) { return viewX; } else if (zoom < 0) { return viewX << -zoom; } else { return viewX >> zoom; } } /** * Get the Y coordinate in image coordinates of the centre of the view. * * @return The Y coordinate in image coordinates of the centre of the view. */ public int getViewY() { if (zoom == 0) { return viewY; } else if (zoom < 0) { return viewY << -zoom; } else { return viewY >> zoom; } } /** * Get the combined and zoom-corrected extent or main area of interest of * all the tile providers, in component coordinates. * * @return The combined and zoom-corrected extent or main area of interest * of all the tile providers, or {@code null} if there are no tile * providers configured, or none of them indicate an extent. * @see TileProvider#getExtent() */ public Rectangle getExtent() { Rectangle extent = null; for (TileProvider tileProvider: tileProviders.values()) { Rectangle providerExtent = tileProvider.getExtent(); if (providerExtent != null) { providerExtent = getTileBounds(providerExtent.x, providerExtent.y, providerExtent.width, providerExtent.height, zoom); if (extent == null) { extent = providerExtent; } else { extent = extent.union(providerExtent); } } } return extent; } /** * Get the coordinate in image coordinates of the centre of the view. * * @return The coordinate in image coordinates of the centre of the view. */ public Point getViewLocation() { if (zoom == 0) { return new Point(viewX, viewY); } else if (zoom < 0) { return new Point(viewX << -zoom, viewY << -zoom); } else { return new Point(viewX >> zoom, viewY >> zoom); } } /** * Get the current zoom level in powers of two. * * @return The current zoom level as a power of two. The scale is * 2zoom. */ public int getZoom() { return zoom; } /** * Set the zoom level in powers of two. 0 means "native size"; positive * numbers zoom in (result in a larger scale); negative numbers zoom out * (result in a smaller scale). * * @param zoom The new zoom level as a power of two. The scale will be * 2zoom. */ public void setZoom(int zoom) { setZoom(zoom, xOffset, yOffset); } /** * Set the zoom level in powers of two. 0 means "native size"; positive * numbers zoom in (result in a larger scale); negative numbers zoom out * (result in a smaller scale). * * @param zoom The new zoom level as a power of two. The scale will be * 2zoom. */ public void setZoom(int zoom, int locusX, int locusY) { // TODO: implement zoom locus support if (zoom != this.zoom) { int dZoom = zoom - this.zoom; this.zoom = zoom; if (queue != null) { queue.clear(); } synchronized (TILE_CACHE_LOCK) { for (TileProvider tileProvider: tileProviders.values()) { if (tileProvider.isZoomSupported()) { // Only use the tile provider's own zoom support for // zooming out: tileProvider.setZoom(((zoom + tileProviderZoom.getOrDefault(tileProvider, 0)) <= tileProviderZoomCutoff) ? (zoom + tileProviderZoom.getOrDefault(tileProvider, 0)) : tileProviderZoomCutoff); } dirtyTileCaches.put(tileProvider, new HashMap<>()); tileCaches.put(tileProvider, new HashMap<>()); } } // Adjust view location, since it is in unzoomed coordinates if (dZoom < 0) { viewX >>= -dZoom; viewY >>= -dZoom; } else { viewX <<= dZoom; viewY <<= dZoom; } fireViewChangedEvent(); repaint(); } } public void resetZoom() { setZoom((getUIScaleInt() == 1) ? 0 : 1); } /** * Get the coordinates in image coordinates of the marker (displayed as a red * crosshair), if configured. * * @return The coordinates in image coordinates of the marker, or * {@code null} if no marker is configured. */ public Point getMarkerCoords() { return paintMarker ? new Point(markerX, markerY) : null; } /** * Set the coordinates in image coordinates where a red crosshair marker * should be displayed, if any. * * @param markerCoords The coordinates in image coordinates where a red * crosshair marker should be displayed, or * {@code null} if no marker should be displayed. */ public void setMarkerCoords(Point markerCoords) { if (markerCoords != null) { markerX = markerCoords.x; markerY = markerCoords.y; paintMarker = true; } else { paintMarker = false; } repaint(); } /** * Determine whether the grid is currently painted. * * @return {@code true} if the grid is currently painted. */ public boolean isPaintGrid() { return paintGrid; } /** * Set whether the gid should be painted. * * @param paintGrid {@code true} if the grid should be painted. */ public void setPaintGrid(boolean paintGrid) { if (paintGrid != this.paintGrid) { this.paintGrid = paintGrid; repaint(); } } /** * Get the current size in image coordinates of the grid. * * @return The current size in image coordinates of the grid. */ public int getGridSize() { return gridSize; } /** * Set the size in image coordinates at which the grid should be painted. * * @param gridSize The size in image coordinates at which the grid should be * painted. */ public void setGridSize(int gridSize) { if (gridSize != this.gridSize) { this.gridSize = gridSize; repaint(); } } /** * Centre the view on a particular location in image coordinates. * * @param coords The coordinates in image coordinates of the location to * centre. */ public void moveTo(Point coords) { moveTo(coords.x, coords.y); } /** * Centre the view on a particular location in image coordinates. * * @param x The X coordinate in image coordinates of the location to centre. * @param y The Y coordinate in image coordinates of the location to centre. */ public void moveTo(int x, int y) { if (zoom < 0) { x >>= -zoom; y >>= -zoom; } else if (zoom > 0) { x <<= zoom; y <<= zoom; } if ((viewX != x) || (viewY != y)) { viewX = x; viewY = y; fireViewChangedEvent(); repaint(); } } /** * Centre the view on the currently configured marker. Does nothing if no * marker is configured. */ public void moveToMarker() { if (paintMarker) { moveTo(markerX, markerY); } } /** * Centre the view on the image origin (coordinates 0,0). */ public void moveToOrigin() { moveTo(0, 0); } /** * Move the view by a number of pixels. The actual movement in image * coordinates may be different if the zoom level is not zero. * * @param dx The number of pixels to move the view right. * @param dy The number of pixels to move the view down. */ public void moveBy(int dx, int dy) { if ((dx != 0) || (dy != 0)) { viewX += dx; viewY += dy; fireViewChangedEvent(); repaint(); } } /** * Immediately throws away and refreshes all tiles of all tile providers. */ public void refresh() { refresh(false); } /** * Refreshes all tiles of all tile providers. * * @param keepDirtyTiles Whether to keep displaying the old tiles while the * new ones are being rendered. */ public void refresh(boolean keepDirtyTiles) { queue.clear(); synchronized (TILE_CACHE_LOCK) { for (TileProvider tileProvider: tileProviders.values()) { if (keepDirtyTiles) { Map> dirtyTileCache = tileCaches.get(tileProvider); // Remove all dirty tiles which don't exist any more // according to the tile provider, otherwise they won't be // repainted for (Iterator>> i = dirtyTileCache.entrySet().iterator(); i.hasNext(); ) { Map.Entry> entry = i.next(); Point coords = entry.getKey(); if (! tileProvider.isTilePresent(coords.x, coords.y)) { i.remove(); } } dirtyTileCaches.put(tileProvider, dirtyTileCache); } else { dirtyTileCaches.put(tileProvider, new HashMap<>()); } tileCaches.put(tileProvider, new HashMap<>()); } } repaint(); } /** * Refresh a single tile for a single tile provider. If the tile is visible * it will immediately be scheduled for background rendering. Otherwise it * will be scheduled when it next becomes visible. If there is a fresh tile * image in the cache that will be used as a stale tile image until it has * been re-rendered. * * @param tileProvider The tile provider. * @param x The X coordinate of the tile in tiles relative to the image * origin. * @param y The Y coordinate of the tile in tiles relative to the image * origin. */ public void refresh(TileProvider tileProvider, int x, int y) { synchronized (TILE_CACHE_LOCK) { final Point coords = new Point(x, y); final Map> tileCache = tileCaches.get(tileProvider); final Reference tileRef = tileCache.remove(coords); final int effectiveZoom = (tileProvider.isZoomSupported() && (zoom < 0)) ? 0 : zoom; if (tileRef != RENDERING) { final Image tile = (tileRef != null) ? tileRef.get() : null; if (tile != null) { // The old tile is still available; move it to the dirty // tile cache so we have something to paint while the tile // is being rendered dirtyTileCaches.get(tileProvider).put(coords, tileRef); } if (isTileVisible(x, y, effectiveZoom)) { // The tile is visible; immediately schedule it to be // rendered scheduleTile(tileCache, coords, tileProvider, dirtyTileCaches.get(tileProvider), effectiveZoom, (tile != NO_TILE) ? tile : null); } } else if (isTileVisible(x, y, effectiveZoom)) { // The tile is already rendering, but apparently it has changed so schedule it anyway (if visible) scheduleTile(tileCache, coords, tileProvider, dirtyTileCaches.get(tileProvider), effectiveZoom, null); } } } /** * Refresh a number of tiles for a single tile provider. Tiles that are * currently visible will immediately be scheduled for background rendering. * Otherwise they will be scheduled when they next become visible. Any fresh * tile images in the cache will be used as stale tile images until the * tiles are re-rendered. * * @param tileProvider The tile provider. * @param tiles A set of tile coordinates of the tiles to refresh, in tiles * relative to the image origin. */ public void refresh(TileProvider tileProvider, Set tiles) { synchronized (TILE_CACHE_LOCK) { final Map> tileCache = tileCaches.get(tileProvider); final Map> dirtyTileCache = dirtyTileCaches.get(tileProvider); final int effectiveZoom = (tileProvider.isZoomSupported() && (zoom < 0)) ? 0 : zoom; for (Point coords: tiles) { final Reference tileRef = tileCache.remove(coords); if (tileRef != RENDERING) { final Image tile = (tileRef != null) ? tileRef.get() : null; if (tile != null) { // The old tile is still available; move it to the dirty // tile cache so we have something to paint while the tile // is being rendered dirtyTileCache.put(coords, tileRef); } if (isTileVisible(coords.x, coords.y, effectiveZoom)) { // The tile is visible; immediately schedule it to be // rendered scheduleTile(tileCache, coords, tileProvider, dirtyTileCache, effectiveZoom, (tile != NO_TILE) ? tile : null); } } else if (isTileVisible(coords.x, coords.y, effectiveZoom)) { // The tile is already rendering, but apparently it has changed so schedule it anyway (if visible) scheduleTile(tileCache, coords, tileProvider, dirtyTileCache, effectiveZoom, null); } } } } /** * Reset the location to the origin, and the zoom level to -2. */ @Deprecated public void reset() { viewX = 0; viewY = 0; if (zoom == -2) { fireViewChangedEvent(); } else { setZoom(-2); } repaint(); } /** * Transform coordinates from image (world) coordinates to component (pixels * relative to the top left corner) coordinates, taking the current zoom * level into account. */ public final Point worldToView(Point coords) { return worldToView(coords.x, coords.y); } /** * Transform coordinates from image (world) coordinates to component (pixels * relative to the top left corner) coordinates, taking the current zoom * level into account. */ public final Point worldToView(int x, int y) { return (zoom == 0) ? new Point(x - viewX + xOffset, y - viewY + yOffset) : ((zoom < 0) ? new Point((x >> -zoom) - viewX + xOffset, (y >> -zoom) - viewY + yOffset) : new Point((x << zoom) - viewX + xOffset, (y << zoom) - viewY + yOffset)); } /** * Transform coordinates from component (pixels relative to the top left * corner) coordinates to image (world) coordinates, taking the current zoom * level into account. */ public final Point viewToWorld(Point coords) { return viewToWorld(coords.x, coords.y, zoom); } /** * Transform coordinates from component (pixels relative to the top left * corner) coordinates to image (world) coordinates, taking the current zoom * level into account. This version does not take tile-provider-specific * offset into acount. */ public final Point viewToWorld(int x, int y) { return viewToWorld(x, y, zoom); } /** * Transform coordinates from component (pixels relative to the top left * corner) coordinates to image (world) coordinates, using a specific zoom * level. This version does not take per-tile-provider offsets into account. */ public final Point viewToWorld(Point coords, int effectiveZoom) { return viewToWorld(coords.x, coords.y, effectiveZoom); } /** * Transform coordinates from component (pixels relative to the top left * corner) coordinates to image (world) coordinates, using a specific zoom * level. This version does not take per-tile-provider offsets into account. */ public final Point viewToWorld(int x, int y, int effectiveZoom) { return (effectiveZoom == 0) ? new Point(x + viewX - xOffset, y + viewY - yOffset) : ((effectiveZoom < 0) ? new Point((x + viewX - xOffset) << -effectiveZoom, (y + viewY - yOffset) << -effectiveZoom) : new Point((x + viewX - xOffset) >> effectiveZoom, (y + viewY - yOffset) >> effectiveZoom)); } /** * Transform coordinates from image (world) coordinates to component (pixels * relative to the top left corner) coordinates, taking the current zoom * level into account. */ public final Rectangle worldToView(Rectangle coords) { return worldToView(coords.x, coords.y, coords.width, coords.height, zoom); } /** * Transform coordinates from image (world) coordinates to component (pixels * relative to the top left corner) coordinates, taking the current zoom * level into account. */ public final Rectangle worldToView(int x, int y, int width, int height) { return worldToView(x, y, width, height, zoom); } /** * Transform coordinates from image (world) coordinates to component (pixels * relative to the top left corner) coordinates, using a specific zoom * level. */ public final Rectangle worldToView(Rectangle coords, int effectiveZoom) { return worldToView(coords.x, coords.y, coords.width, coords.height, effectiveZoom); } /** * Transform coordinates from image (world) coordinates to component (pixels * relative to the top left corner) coordinates, using a specific zoom * level. This version does not take tile-provider-specific offsets into * account. */ public final Rectangle worldToView(int x, int y, int width, int height, int effectiveZoom) { return (effectiveZoom == 0) ? new Rectangle(x - viewX + xOffset, y - viewY + yOffset, width, height) : ((effectiveZoom < 0) ? new Rectangle((x >> -effectiveZoom) - viewX + xOffset, (y >> -effectiveZoom) - viewY + yOffset, width >> -effectiveZoom, height >> -effectiveZoom) : new Rectangle((x << effectiveZoom) - viewX + xOffset, (y << effectiveZoom) - viewY + yOffset, width << effectiveZoom, height << effectiveZoom)); } /** * Transform coordinates from component (pixels relative to the top left * corner) coordinates to image (world) coordinates, taking the current zoom * level into account. */ public final Rectangle viewToWorld(Rectangle coords) { return viewToWorld(coords.x, coords.y, coords.width, coords.height); } /** * Transform coordinates from component (pixels relative to the top left * corner) coordinates to image (world) coordinates, taking the current zoom * level into account. */ public final Rectangle viewToWorld(int x, int y, int width, int height) { return (zoom == 0) ? new Rectangle(x + viewX - xOffset, y + viewY - yOffset, width, height) : ((zoom < 0) ? new Rectangle((x + viewX - xOffset) << -zoom, (y + viewY - yOffset) << -zoom, width << -zoom, height << -zoom) : new Rectangle((x + viewX - xOffset) >> zoom, (y + viewY - yOffset) >> zoom, width >> zoom, height >> zoom)); } /** * Get the view listener. * * @return The currently configured view listener, or {@code null} if none is configured. */ public ViewListener getViewListener() { return viewListener; } /** * Get the currently configured view listener, if any. * * @param viewListener The currently configured view listener, or * {@code null} if there is none. */ public void setViewListener(ViewListener viewListener) { this.viewListener = viewListener; } /** * Add an overlay. An overlay is an image which is overlaid on the viewport, * on the left or right edge of the view, vertically tracking some other * component. The image may be partially transparent. * * @param key The unique key of the overlay to add. * @param x The horizontal distance from the left edge to paint the overlay, * or if negative: the horizontal distance from the right edge. * @param componentToTrack The component of which the height should be * tracked. * @param overlay The image to overlay on the view. */ public void addOverlay(String key, int x, Component componentToTrack, BufferedImage overlay) { overlays.put(key, new Overlay(componentToTrack, key, x, overlay)); repaint(); } /** * Remove a previously added overlay. * * @param key The unique key of the overlay to remove. */ public void removeOverlay(String key) { if (overlays.containsKey(key)) { overlays.remove(key); repaint(); } } /** * Get the colour in which the grid is painted. * * @return The colour in which the grid is painted. */ public Color getGridColour() { return gridColour; } /** * Set the colour in which to paint the grid. * * @param gridColour The colour in which to paint the grid. */ public void setGridColour(Color gridColour) { if (gridColour == null) { throw new NullPointerException(); } if (! gridColour.equals(this.gridColour)) { this.gridColour = gridColour; repaint(); } } public BufferedImage getBackgroundImage() { return backgroundImage; } public void setBackgroundImage(BufferedImage backgroundImage) { this.backgroundImage = backgroundImage; repaint(); } public BackgroundImageMode getBackgroundImageMode() { return backgroundImageMode; } public void setBackgroundImageMode(BackgroundImageMode backgroundImageMode) { if (backgroundImageMode != this.backgroundImageMode) { this.backgroundImageMode = backgroundImageMode; if (backgroundImage != null) { repaint(); } } } public boolean isInhibitUpdates() { return inhibitUpdates; } public void setInhibitUpdates(boolean inhibitUpdates) { if (inhibitUpdates != this.inhibitUpdates) { this.inhibitUpdates = inhibitUpdates; if (! inhibitUpdates) { refresh(true); } } } public int getLabelScale() { return labelScale; } public void setLabelScale(int labelScale) { if (labelScale != this.labelScale) { this.labelScale = labelScale; if (paintGrid) { repaint(); } } } public void setTileProviderZoom(TileProvider tileProvider, int zoom) { tileProviderZoom.put(tileProvider, zoom); tileProvider.setZoom(((this.zoom + zoom) <= tileProviderZoomCutoff) ? (this.zoom + zoom) : tileProviderZoomCutoff); repaint(); } /** * Get the currently visible area in world coordinates. * * @return The currently visible area in world coordinates. */ public Rectangle getVisibleArea() { return new Rectangle(viewToWorld(0, 0, getWidth(), getHeight())); } /** * Determine whether a tile is currently visible in the viewport. * * @param x The X coordinate of the tile to check for visibility. * @param y The Y coordinate of the tile to check for visibility. * @param effectiveZoom The zoom level to take into account. * @return {@code true} if any part of the specified tile intersects the viewport. */ protected final boolean isTileVisible(int x, int y, int effectiveZoom) { return new Rectangle(0, 0, getWidth(), getHeight()).intersects(getTileBounds(x, y, effectiveZoom)); } /** * Get the bounds of a tile in component coordinates, taking the current * zoom level into account. * * @param x The X coordinate of the tile for which to determine the bounds. * @param y The X coordinate of the tile for which to determine the bounds. * @return The area in component coordinates taken up by the specified tile. */ protected final Rectangle getTileBounds(int x, int y) { return getTileBounds(x, y, zoom); } /** * Get the bounds of a tile in component coordinates, taking a specific * zoom level into account. This version does not take per-tile-provider * offsets into account. * * @param x The X coordinate of the tile for which to determine the bounds. * @param y The X coordinate of the tile for which to determine the bounds. * @param effectiveZoom The zoom level to take into account. * @return The area in component coordinates taken up by the specified tile. */ protected final Rectangle getTileBounds(int x, int y, int effectiveZoom) { return worldToView(x << TILE_SIZE_BITS, y << TILE_SIZE_BITS, TILE_SIZE, TILE_SIZE, effectiveZoom); } /** * Get the bounds of a rectangular area of tiles in component coordinates, * taking a specific zoom level into account. This version does not take * tile-provider-specific offsets into account. * * @param x The X coordinate of the top left tile of the area for which to * determine the bounds. * @param y The X coordinate of the top left tile of the area for which to * determine the bounds. * @param width The width in tiles of the area for which to determine the * bounds. * @param height The height in tiles of the area for which to determine the * bounds. * @param effectiveZoom The zoom level to take into account. * @return The area in component coordinates taken up by the specified * rectangle of tiles. */ protected final Rectangle getTileBounds(int x, int y, int width, int height, int effectiveZoom) { return worldToView(x << TILE_SIZE_BITS, y << TILE_SIZE_BITS, TILE_SIZE * width, TILE_SIZE * height, effectiveZoom); } /** * Apply translation and scaling to a graphics canvas according to the * current location and zoom settings such that it can be painted using * image coordinates. * * @param g2 The graphics canvas to which to apply the transforms. * @return The scaling factor to apply to image coordinates to account for * the zoom level. */ protected final float transformGraphics(Graphics2D g2) { g2.translate(getWidth() / 2, getHeight() / 2); g2.translate(-viewX, -viewY); if (zoom != 0) { float scale = (float) Math.pow(2.0, zoom); g2.scale(scale, scale); return scale; } else { return 1.0f; } } private void paintMarkerIfApplicable(Graphics g2) { if (paintMarker) { Color savedColour = g2.getColor(); try { g2.setColor(Color.RED); Point markerCoords = worldToView(markerX, markerY); g2.drawLine(markerCoords.x - 5, markerCoords.y, markerCoords.x + 5, markerCoords.y); g2.drawLine(markerCoords.x, markerCoords.y - 5, markerCoords.x, markerCoords.y + 5); } finally { g2.setColor(savedColour); } } } private void startRenderersIfApplicable() { if ((tileRenderers == null) && isDisplayable()) { // The component is already visible but had no tile providers // installed yet; start the background threads if (logger.isDebugEnabled()) { logger.debug("Starting " + threads + " tile rendering threads"); } queue = new PriorityBlockingQueue<>(); tileRenderers = new ThreadPoolExecutor(threads, threads, 0, TimeUnit.MILLISECONDS, queue); } } private void paintGridIfApplicable(Graphics2D g2) { if (! paintGrid) { return; } // Save the current graphics canvas configuration final Color savedColour = g2.getColor(); final Stroke savedStroke = g2.getStroke(); final Font savedFont = g2.getFont(); final Object savedTextAAHint = g2.getRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING); try { int effectiveGridSize = gridSize; if (zoom < 0) { // Increase the effective grid size if necessary to prevent the // lines being too close together int minGridSize = Math.min(gridSize, 32); while ((effectiveGridSize >> -zoom) < minGridSize) { effectiveGridSize *= 2; } } // Determine which grid lines to draw, in image coordinates final Rectangle clipInWorld = viewToWorld(g2.getClipBounds()); final int x1 = ((clipInWorld.x / effectiveGridSize) - 1) * effectiveGridSize; final int x2 = ((clipInWorld.x + clipInWorld.width) / effectiveGridSize + 1) * effectiveGridSize; final int y1 = ((clipInWorld.y / effectiveGridSize) - 1) * effectiveGridSize; final int y2 = ((clipInWorld.y + clipInWorld.height) / effectiveGridSize + 1) * effectiveGridSize; g2.setColor(gridColour); // Determine the exclusion zone for preventing labels from being // obscured by grid lines or other labels final Rectangle2D fontBounds = BOLD_FONT.getStringBounds((labelScale < 5) ? "-00000" : "-000000", g2.getFontRenderContext()); final int fontHeight = (int) Math.round(fontBounds.getHeight()), fontWidth = (int) Math.round(fontBounds.getWidth()); final int leftClear = fontWidth + 4, topClear = fontHeight + 6; // Create and install strokes and fonts final Stroke normalStroke = new BasicStroke(1, BasicStroke.CAP_BUTT, BasicStroke.JOIN_MITER, 10.0f, new float[]{2f, 2f}, 0.0f); final Stroke regionBorderStroke = new BasicStroke(1, BasicStroke.CAP_BUTT, BasicStroke.JOIN_MITER, 10.0f, new float[]{6f, 2f}, 0.0f); final boolean drawRegionBorders = (gridSize <= 512) && (gridSize & (gridSize - 1)) == 0; // Power of two final int width = getWidth(), height = getHeight(); int xLabelSkip = effectiveGridSize, yLabelSkip = effectiveGridSize; final float scale = (float) Math.pow(2.0, getZoom()); // Determine per how many grid lines minimum a label can be draw // so that they don't obscure one another, for the horizontal and // vertical direction while ((xLabelSkip * scale) < fontWidth) { xLabelSkip += effectiveGridSize; } while ((yLabelSkip * scale) < fontHeight) { yLabelSkip += effectiveGridSize; } // Initial setup of the graphics canvas g2.setStroke(normalStroke); g2.setFont(NORMAL_FONT); boolean normalFontInstalled = true; g2.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_GASP); boolean normalStrokeInstalled = true; // Draw the vertical grid lines and corresponding labels for (int x = x1; x <= x2; x += effectiveGridSize) { if ((x == 0) || (drawRegionBorders && ((x % 512) == 0))) { g2.setStroke(regionBorderStroke); normalStrokeInstalled = false; } else if (!normalStrokeInstalled) { g2.setStroke(normalStroke); normalStrokeInstalled = true; } Point lineStartInView = worldToView(x, 0); if (lineStartInView.x + 2 >= leftClear) { if ((x % xLabelSkip) == 0) { g2.drawLine(lineStartInView.x, 0, lineStartInView.x, height); if (drawRegionBorders && ((x % 512) == 0)) { g2.setFont(BOLD_FONT); normalFontInstalled = false; } else if (!normalFontInstalled) { g2.setFont(NORMAL_FONT); normalFontInstalled = true; } g2.drawString(Integer.toString(x * labelScale), lineStartInView.x + 2, fontHeight + 2); } else { g2.drawLine(lineStartInView.x, topClear, lineStartInView.x, height); } } } // Draw the horizontal grid lines and corresponding labels for (int y = y1; y <= y2; y += effectiveGridSize) { if ((y == 0) || (drawRegionBorders && ((y % 512) == 0))) { g2.setStroke(regionBorderStroke); normalStrokeInstalled = false; } else if (!normalStrokeInstalled) { g2.setStroke(normalStroke); normalStrokeInstalled = true; } Point lineStartInView = worldToView(0, y); if ((y % yLabelSkip) == 0) { if (lineStartInView.y + 2 >= topClear) { g2.drawLine(0, lineStartInView.y, width, lineStartInView.y); } if (drawRegionBorders && ((y % 512) == 0)) { g2.setFont(BOLD_FONT); normalFontInstalled = false; } else if (!normalFontInstalled) { g2.setFont(NORMAL_FONT); normalFontInstalled = true; } g2.drawString(Integer.toString(y * labelScale), 2, lineStartInView.y - 2); } else if (lineStartInView.y + 2 >= topClear) { g2.drawLine(leftClear, lineStartInView.y, width, lineStartInView.y); } } } finally { // Restore the original graphics canvas configuration g2.setColor(savedColour); g2.setStroke(savedStroke); g2.setFont(savedFont); g2.setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, savedTextAAHint); } } @Override protected void paintComponent(Graphics g) { final Graphics2D g2 = (Graphics2D) g; Rectangle clipBounds = g2.getClipBounds(); g2.setColor(getBackground()); paintBackground(g2, clipBounds); if (tileProviders.isEmpty()) { return; } g2.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_NEAREST_NEIGHBOR); GraphicsConfiguration gc = getGraphicsConfiguration(); for (TileProvider tileProvider: tileProviders.values()) { final Integer tileProviderZoom = this.tileProviderZoom.getOrDefault(tileProvider, 0); final int effectiveZoom = (tileProvider.isZoomSupported() && ((zoom + tileProviderZoom) < tileProviderZoomCutoff)) ? 0 : (zoom + tileProviderZoom - tileProviderZoomCutoff); if (logger.isTraceEnabled()) { logger.trace("Provider {}: zoomSupported: {}, this.zoom: {}, tileProviderZoom: {}, effectiveZoom: {}, tileProvider.getZoom(): {}", tileProvider, tileProvider.isZoomSupported(), zoom, tileProviderZoom, effectiveZoom, tileProvider.getZoom()); } final Point topLeftTileCoords = viewToWorld(clipBounds.getLocation(), effectiveZoom); final int leftTile = topLeftTileCoords.x >> TILE_SIZE_BITS; final int topTile = topLeftTileCoords.y >> TILE_SIZE_BITS; final Point bottomRightTileCoords = viewToWorld(new Point(clipBounds.x + clipBounds.width - 1, clipBounds.y + clipBounds.height - 1), effectiveZoom); final int rightTile = bottomRightTileCoords.x >> TILE_SIZE_BITS; final int bottomTile = bottomRightTileCoords.y >> TILE_SIZE_BITS; final int middleTileX = (leftTile + rightTile) / 2; final int middleTileY = (topTile + bottomTile) / 2; final int radius = Math.max( Math.max(middleTileX - leftTile, rightTile - middleTileX), Math.max(middleTileY - topTile, bottomTile - middleTileY)); // Paint the tiles in a spiralish fashion, so that missing tiles are generated in that order paintTile(g2, gc, tileProvider, middleTileX, middleTileY, effectiveZoom); for (int r = 1; r <= radius; r++) { for (int i = 0; i < (r * 2); i++) { int tileX = middleTileX + i - r, tileY = middleTileY - r; if ((tileX >= leftTile) && (tileX <= rightTile) && (tileY >= topTile) && (tileY <= bottomTile)) { paintTile(g2, gc, tileProvider, tileX, tileY, effectiveZoom); } tileX = middleTileX + r; tileY = middleTileY + i - r; if ((tileX >= leftTile) && (tileX <= rightTile) && (tileY >= topTile) && (tileY <= bottomTile)) { paintTile(g2, gc, tileProvider, tileX, tileY, effectiveZoom); } tileX = middleTileX + r - i; tileY = middleTileY + r; if ((tileX >= leftTile) && (tileX <= rightTile) && (tileY >= topTile) && (tileY <= bottomTile)) { paintTile(g2, gc, tileProvider, tileX, tileY, effectiveZoom); } tileX = middleTileX - r; tileY = middleTileY - i + r; if ((tileX >= leftTile) && (tileX <= rightTile) && (tileY >= topTile) && (tileY <= bottomTile)) { paintTile(g2, gc, tileProvider, tileX, tileY, effectiveZoom); } } } } paintGridIfApplicable(g2); paintMarkerIfApplicable(g2); final int myWidth = getWidth(); final int myHeight = getHeight(); if (paintCentre) { final int middleX = myWidth / 2; final int middleY = myHeight / 2; g2.setColor(Color.BLACK); g2.drawLine(middleX - 4, middleY + 1, middleX + 6, middleY + 1); g2.drawLine(middleX + 1, middleY - 4, middleX + 1, middleY + 6); g2.setColor(Color.WHITE); g2.drawLine(middleX - 5, middleY, middleX + 5, middleY); g2.drawLine(middleX, middleY - 5, middleX, middleY + 5); } paintOverlays(g2); // Unschedule tiles which were scheduled to be rendered but are no // longer visible final Rectangle viewBounds = new Rectangle(0, 0, myWidth, myHeight); synchronized (TILE_CACHE_LOCK) { for (Iterator i = queue.iterator(); i.hasNext(); ) { TileRenderJob job = (TileRenderJob) i.next(); if (! getTileBounds(job.coords.x, job.coords.y, job.effectiveZoom).intersects(viewBounds)) { i.remove(); // Remove the RENDERING flag for this tile from the cache, // otherwise it won't be rendered the next time it becomes // visible: tileCaches.get(job.tileProvider).remove(job.coords); } } } } private void paintBackground(Graphics2D g2, Rectangle clipBounds) { if (backgroundImage != null) { int width = getWidth(), height = getHeight(); switch (backgroundImageMode) { case CENTRE: g2.fillRect(clipBounds.x, clipBounds.y, clipBounds.width, clipBounds.height); int imageWidth = backgroundImage.getWidth(); int imageHeight = backgroundImage.getHeight(); int imageX = (width - imageWidth) / 2; int imageY = (height - imageHeight) / 2; if (clipBounds.intersects(imageX, imageY, imageWidth, imageHeight)) { g2.drawImage(backgroundImage, imageX, imageY, null); } break; case CENTRE_REPEAT: if (backgroundImage.getTransparency() != Transparency.OPAQUE) { g2.fillRect(clipBounds.x, clipBounds.y, clipBounds.width, clipBounds.height); } repeatImage(g2, clipBounds, backgroundImage, (width - backgroundImage.getWidth()) / 2, (height - backgroundImage.getHeight()) / 2, backgroundImage.getWidth(), backgroundImage.getHeight()); break; case FIT: case FIT_REPEAT: imageWidth = backgroundImage.getWidth(); imageHeight = backgroundImage.getHeight(); float myRatio = (float) width / height; float imageRatio = (float) imageWidth / imageHeight; if (imageRatio > myRatio) { imageWidth = width; imageHeight = (int) (imageWidth / imageRatio); } else { imageHeight = height; imageWidth = (int) (imageHeight * imageRatio); } imageX = (width - imageWidth) / 2; imageY = (height - imageHeight) / 2; g2.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); if (backgroundImageMode == TiledImageViewer.BackgroundImageMode.FIT) { g2.fillRect(clipBounds.x, clipBounds.y, clipBounds.width, clipBounds.height); if (clipBounds.intersects(imageX, imageY, imageWidth, imageHeight)) { g2.drawImage(backgroundImage, imageX, imageY, imageWidth, imageHeight, null); } } else { if (backgroundImage.getTransparency() != Transparency.OPAQUE) { g2.fillRect(clipBounds.x, clipBounds.y, clipBounds.width, clipBounds.height); } repeatImage(g2, clipBounds, backgroundImage, imageX, imageY, imageWidth, imageHeight); } break; case REPEAT: if (backgroundImage.getTransparency() != Transparency.OPAQUE) { g2.fillRect(clipBounds.x, clipBounds.y, clipBounds.width, clipBounds.height); } repeatImage(g2, clipBounds, backgroundImage, 0, 0, backgroundImage.getWidth(), backgroundImage.getHeight()); break; case STRETCH: if (backgroundImage.getTransparency() != Transparency.OPAQUE) { g2.fillRect(clipBounds.x, clipBounds.y, clipBounds.width, clipBounds.height); } g2.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); g2.drawImage(backgroundImage, 0, 0, width, height, null); break; } } else { g2.fillRect(clipBounds.x, clipBounds.y, clipBounds.width, clipBounds.height); } } private void repeatImage(final Graphics2D g2, Rectangle clipBounds, final BufferedImage image, int x, int y, final int width, final int height) { while (y > 0) y-= height; do { while (x > 0) x -= width; do { if (clipBounds.intersects(x, y, width, height)) { g2.drawImage(image, x, y, width, height, null); } x += width; } while (x < getWidth()); y += height; } while (y < getHeight()); } private void paintOverlays(Graphics2D g2) { overlays.values().forEach(overlay -> { int x = overlay.x >= 0 ? overlay.x : getWidth() + overlay.x; Point coords = SwingUtilities.convertPoint(overlay.componentToTrack, 0, 0, this); g2.drawImage(overlay.image, x, coords.y, null); }); } /** * Immediately paint a specific tile from a specific provider. If a fresh * tile is not available the tile will be scheduled for repainting in the * background, unless the tile provider indicates that the tile is not * present. If a stale version of the tile is available that will be * painted, otherwise the area of the tile will be filled with the canvas' * current colour. * * @param g2 The canvas on which to paint the tile. * @param gc The graphics configuration associated with the canvas; used for * volatile (accelerated) image management. * @param tileProvider The tile provider. * @param x The X coordinate of the tile to paint, in tiles relative to the * image origin. * @param y The Y coordinate of the tile to paint, in tiles relative to the * image origin. * @param effectiveZoom The zoom level to apply. * @throws UnknownTileProviderException If the specified tile provider is * not configured on this image viewer. */ private void paintTile(Graphics2D g2, GraphicsConfiguration gc, TileProvider tileProvider, int x, int y, int effectiveZoom) { final Rectangle tileBounds = getTileBounds(x, y, effectiveZoom); final Image tile = getTile(tileProvider, x, y, effectiveZoom, gc); if (tile != null) { if ((zoom + tileProviderZoom.getOrDefault(tileProvider, 0)) > 0) { g2.drawImage(tile, tileBounds.x, tileBounds.y, tileBounds.width, tileBounds.height, this); } else { g2.drawImage(tile, tileBounds.x, tileBounds.y, this); } } } /** * Get a cached copy of a specific tile from a specific provider. If a fresh * tile is available it will be returned. Otherwise the tile will be * scheduled for repainting in the background, unless the tile provider * indicates that the tile is not present. If a stale version of the tile is * available that will be returned, otherwise {@code null} will be * returned. * * @param tileProvider The tile provider. * @param x The X coordinate of the tile to get, in tiles relative to the * image origin. * @param y The Y coordinate of the tile to get, in tiles relative to the * image origin. * @param effectiveZoom The zoom level to apply. * @param gc The graphics configuration to use for volatile (accelerated) * image management. * @return The freshest copy of the tile available from the cache, or * {@code null} if no version of the tile is available, or if the tile * is not present according to the tile provider. */ private Image getTile(TileProvider tileProvider, int x, int y, int effectiveZoom, GraphicsConfiguration gc) { synchronized (TILE_CACHE_LOCK) { final Point coords = new Point(x, y); final Map> tileCache = tileCaches.get(tileProvider), dirtyTileCache = dirtyTileCaches.get(tileProvider); if ((tileCache == null) || (dirtyTileCache == null)) { // We have reports from the wild about this happening. It has to // do with the 3D dynmap previews and happens when adding custom // objects. TODO: how is that possible? Race condition? Threading issue? logger.warn("tileCache or dirtyTileCache null! Proceeding without a tile..."); return null; } final Reference ref = tileCache.get(coords); if (ref == RENDERING) { // The tile is already queued for rendering. Return a dirty tile if // we have one. return getDirtyTile(coords, dirtyTileCache, gc); } else if (ref != null) { final Image tile = ref.get(); if (tile == null) { // The image was garbage collected; remove the reference from // the cache and schedule it to be rendered again tileCache.remove(coords); scheduleTile(tileCache, coords, tileProvider, dirtyTileCache, effectiveZoom, null); return getDirtyTile(coords, dirtyTileCache, gc); } else if (tile == NO_TILE) { // There is no tile here according to the tile provider return null; } else if (tile instanceof VolatileImage) { switch (((VolatileImage) tile).validate(gc)) { case VolatileImage.IMAGE_OK: return tile; case VolatileImage.IMAGE_RESTORED: // The image was restored and the contents "may" // have been affected. schedule it to be rendered // again // TODO: should we be returning it anyway? scheduleTile(tileCache, coords, tileProvider, dirtyTileCache, effectiveZoom, tile); return tile; case VolatileImage.IMAGE_INCOMPATIBLE: // Weirdly, the image is no longer compatible with // the graphics configuration. Schedule it to be // rendered again. Not much point in checking the // dirty tile cache; those tiles probably aren't // compatible any more also. TODO: can this even // happen? tileCache.remove(coords); scheduleTile(tileCache, coords, tileProvider, dirtyTileCache, effectiveZoom, null); return null; default: throw new InternalError("Unknown validation result"); } } else { return tile; } } else { // Tile not present in cache scheduleTile(tileCache, coords, tileProvider, dirtyTileCache, effectiveZoom, null); return getDirtyTile(coords, dirtyTileCache, gc); } } } /** * Get a cached stale copy of a specific tile from a specific provider. * *

Please note: this method must be invoked while * holding the lock on {@link #TILE_CACHE_LOCK}. * * @param coords The coordinates of the tile to get, in tiles relative to * the image origin. * @param dirtyTileCache The cache from which to get the stale tile. * @param gc The graphics configuration to use for volatile (accelerated) * image management. * @return The stale copy of the tile from the cache, or {@code null} * if the tile is not available from the cache, or if the tile is not * present according to the tile provider. */ private Image getDirtyTile(Point coords, Map> dirtyTileCache, GraphicsConfiguration gc) { final Reference dirtyRef = dirtyTileCache.get(coords); if (dirtyRef != null) { final Image dirtyTile = dirtyRef.get(); if (dirtyTile == null) { // The image was garbage collected; remove the reference // from the cache dirtyTileCache.remove(coords); return null; } else if (dirtyTile == NO_TILE) { // There was no tile here according to the tile provider return null; } else if (dirtyTile instanceof VolatileImage) { switch (((VolatileImage) dirtyTile).validate(gc)) { case VolatileImage.IMAGE_OK: return dirtyTile; case VolatileImage.IMAGE_RESTORED: // The image was restored and the contents "may" have // been affected. Oh well, it was a dirty tile anyway // TODO: should we be returning it anyway? return dirtyTile; case VolatileImage.IMAGE_INCOMPATIBLE: // Weirdly, the image is no longer compatible with the // graphics configuration. Oh well, it was a dirty tile // anyway. TODO: can this even happen? dirtyTileCache.remove(coords); return null; default: throw new InternalError("Unknown validation result"); } } else { return dirtyTile; } } else { return null; } } /** * Schedule a tile for background rendering, or remove it from the cache if * the tile provider indicates it is not present. * * @param tileCache The cache in which the rendered tile should be stored. * @param coords The coordinates of the tile to render, in tiles relative to * the image origin. * @param tileProvider The tile provider. * @param dirtyTileCache The stale tile cache in which any currently cached * version of the tile will be stored as a stale copy. * @param effectiveZoom The zoom level to apply. * @param image The currently cached tile image for the tile, if any. */ private void scheduleTile(final Map> tileCache, final Point coords, final TileProvider tileProvider, final Map> dirtyTileCache, final int effectiveZoom, final Image image) { synchronized (TILE_CACHE_LOCK) { if (tileProvider.isTilePresent(coords.x, coords.y)) { tileCache.put(coords, RENDERING); tileRenderers.execute(new TileRenderJob(tileCache, dirtyTileCache, coords, tileProvider, effectiveZoom, image)); } else { tileCache.put(coords, new SoftReference<>(NO_TILE)); dirtyTileCache.remove(coords); try { repaint(getTileBounds(coords.x, coords.y, effectiveZoom)); } catch (UnknownTileProviderException e) { // This means the tile provider is no longer configured on this image viewer, meaning there's not // much point in us trying to paint it, so give up silently } } } } private void fireViewChangedEvent() { if (viewListener != null) { viewListener.viewChanged(this); } } // ComponentListener @Override public void componentResized(ComponentEvent e) { xOffset = getWidth() / 2; yOffset = getHeight() / 2; fireViewChangedEvent(); repaint(); } @Override public void componentShown(ComponentEvent e) {} @Override public void componentMoved(ComponentEvent e) {} @Override public void componentHidden(ComponentEvent e) {} // TileListener @Override public void tileChanged(final TileProvider source, final int x, final int y) { if (! inhibitUpdates) { if (SwingUtilities.isEventDispatchThread()) { refresh(source, x, y); } else { SwingUtilities.invokeLater(() -> refresh(source, x, y)); } } } @Override public void tilesChanged(final TileProvider source, final Set tiles) { if (! inhibitUpdates) { if (SwingUtilities.isEventDispatchThread()) { refresh(source, tiles); } else { SwingUtilities.invokeLater(() -> refresh(source, tiles)); } } } // MouseListener @Override public void mousePressed(MouseEvent e) { if ((! leftClickDrags) && (e.getButton() == MouseEvent.BUTTON1)) { return; } previousX = e.getX(); previousY = e.getY(); setCursor(Cursor.getPredefinedCursor(Cursor.MOVE_CURSOR)); dragging = true; } @Override public void mouseReleased(MouseEvent e) { if ((! leftClickDrags) && (e.getButton() == MouseEvent.BUTTON1)) { return; } setCursor(Cursor.getPredefinedCursor(Cursor.HAND_CURSOR)); dragging = false; } @Override public void mouseClicked(MouseEvent e) {} @Override public void mouseEntered(MouseEvent e) {} @Override public void mouseExited(MouseEvent e) {} // MouseMotionListener @Override public void mouseDragged(MouseEvent e) { if (! dragging) { return; } int dx = e.getX() - previousX; int dy = e.getY() - previousY; viewX -= dx; viewY -= dy; previousX = e.getX(); previousY = e.getY(); fireViewChangedEvent(); repaint(); } @Override public void mouseMoved(MouseEvent e) {} // HierarchyListener @Override public void hierarchyChanged(HierarchyEvent event) { if (((event.getChangeFlags() & HierarchyEvent.DISPLAYABILITY_CHANGED) != 0)) { // The JIDE framework temporarily removes the view from the // hierarchy when the layout is reset, so we have to be prepared to // reinitialise the render queue when the view is re-added to the // hierarchy if (isDisplayable()) { if (! tileProviders.isEmpty()) { if (logger.isDebugEnabled()) { logger.debug("Starting " + threads + " tile rendering threads"); } queue = new PriorityBlockingQueue<>(); tileRenderers = new ThreadPoolExecutor(threads, threads, 0, TimeUnit.MILLISECONDS, queue); } } else { if (tileRenderers != null) { if (logger.isDebugEnabled()) { logger.debug("Shutting down " + threads + " tile rendering threads"); } queue.clear(); tileRenderers.shutdownNow(); queue = null; tileRenderers = null; } } } } /** * Whether dragging with the left mouse button should pan the image. */ private final boolean leftClickDrags; /** * Whether the centre of the view should be painted as a white crosshair. */ private final boolean paintCentre; /** * The maximum number of background threads to use for rendering tiles. */ private final int threads; /** * A monitor for coordinating multithreaded access to the tile caches. */ private final Object TILE_CACHE_LOCK = new Object(); /** * The currently configured tile providers. */ private final SortedMap tileProviders = new TreeMap<>(); /** * The fresh and stale tile caches for each tile provider. */ private final Map>> tileCaches = new HashMap<>(), dirtyTileCaches = new HashMap<>(); /** * The currently configured overlays. */ private final Map overlays = new HashMap<>(); /** * The zoom level below which to delegate zooming to the tile providers, if they support it. */ private final int tileProviderZoomCutoff; /** * The currently displayed location in scaled coordinates. */ protected int viewX, viewY; /** * The previously displayed location in scaled coordinates. Used during mouse * drag operations. */ protected int previousX, previousY; /** * The image coordindates of the marker (if any) to paint as a red * crosshair. */ protected int markerX, markerY; /** * The offset to apply to the image so that the view coordinates are * displayed in the centre of the view. */ protected int xOffset, yOffset; /** * The zoom level in the form of an exponent of 2. I.e. the scale is 2^n, * meaning that 0 represents no zoom, -1 means half size (so zooming out), * 1 means double size (so zooming in), etc. * *

The default zoom is 1 (200%) for HiDPI displays and 0 (100%) for * regular displays. */ private int zoom = (getUIScale() < 1.5f) ? 0 : 1; /** * The size in image coordinates of the grid to paint, if any. */ private int gridSize = 128; /** * The executor service to use for executing tile render jobs in the * background. */ private ExecutorService tileRenderers; /** * Whether a mouse drag operation is currently in progress. */ private boolean dragging; /** * Whether the marker (a red crosshair) should be painted. */ private boolean paintMarker; /** * Whether the grid should be painted. */ private boolean paintGrid; /** * The queue for submitting background tile render jobs. */ private BlockingQueue queue; /** * The currently configured external listener interested in changes to the * view. */ private ViewListener viewListener; /** * The colour in which to paint the grid. */ private Color gridColour = Color.BLACK; private BufferedImage backgroundImage; private BackgroundImageMode backgroundImageMode = BackgroundImageMode.CENTRE_REPEAT; private volatile boolean inhibitUpdates; private Map tileProviderZoom = new WeakHashMap<>(); private int labelScale = 1; public static final int TILE_SIZE = 128, TILE_SIZE_BITS = 7, TILE_SIZE_MASK = 0x7f; public static final IntegerAttributeKey ADVANCED_SETTING_MAX_TILE_RENDER_THREADS = new IntegerAttributeKey("display.maxTileRenderThreads", 8); static final AtomicLong jobSeq = new AtomicLong(Long.MIN_VALUE); private static final Reference RENDERING = new SoftReference<>(null); private static final VolatileImage NO_TILE = new VolatileImage() { @Override public BufferedImage getSnapshot() {return null;} @Override public int getWidth() {return 0;} @Override public int getHeight() {return 0;} @Override public Graphics2D createGraphics() {return null;} @Override public int validate(GraphicsConfiguration gc) {return 0;} @Override public boolean contentsLost() {return false;} @Override public ImageCapabilities getCapabilities() {return null;} @Override public int getWidth(ImageObserver observer) {return 0;} @Override public int getHeight(ImageObserver observer) {return 0;} @Override public Object getProperty(String name, ImageObserver observer) {return null;} }; private static final Font NORMAL_FONT = new Font("SansSerif", Font.PLAIN, (int) (10 * getUIScale())); private static final Font BOLD_FONT = new Font("SansSerif", Font.BOLD, (int) (10 * getUIScale())); private static final long serialVersionUID = 1L; private static final org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(TiledImageViewer.class); class TileRenderJob implements Runnable, Comparable { TileRenderJob(Map> tileCache, Map> dirtyTileCache, Point coords, TileProvider tileProvider, int effectiveZoom, Image image) { this.tileCache = tileCache; this.dirtyTileCache = dirtyTileCache; this.coords = coords; this.tileProvider = tileProvider; this.effectiveZoom = effectiveZoom; this.image = image; seq = jobSeq.getAndIncrement(); priority = tileProvider.getTilePriority(coords.x, coords.y); } @Override public void run() { if (logger.isTraceEnabled()) { logger.trace("Rendering tile " + coords.x + "," + coords.y); } final int tileSize = tileProvider.getTileSize(); VolatileImage tile; if (image instanceof VolatileImage) { // This image was previously created by us, here, so really it should still be compatible tile = (VolatileImage) image; } else { GraphicsConfiguration gc = getGraphicsConfiguration(); if (gc != null) { tile = gc.createCompatibleVolatileImage(tileSize, tileSize, Transparency.TRANSLUCENT); tile.validate(gc); } else { // No idea how this is possible, but it has been observed in the wild. Perhaps it means the // TiledImageViewer has been removed from the hierarchy? Let's assume that and just give up logger.debug("Not rendering tile " + coords.x + "," + coords.y + " because there is no GraphicsConfiguration"); return; } } if (tileProvider.paintTile(tile, coords.x, coords.y, 0, 0)) { synchronized (TILE_CACHE_LOCK) { tileCache.put(coords, new SoftReference<>(tile)); if (dirtyTileCache.containsKey(coords)) { dirtyTileCache.remove(coords); } } } else { // The tile failed to be painted for some reason; treat it as a permanent condition and register it as // "no tile present" synchronized (TILE_CACHE_LOCK) { tileCache.put(coords, new SoftReference<>(NO_TILE)); if (dirtyTileCache.containsKey(coords)) { dirtyTileCache.remove(coords); } } // Repaint still needed, as a dirty tile may have been painted in its location } try { repaint(getTileBounds(coords.x, coords.y, effectiveZoom)); } catch (UnknownTileProviderException e) { // This means the tile provider is no longer configured on the viewer, meaning there's not much point in // us painting the tile, so just give up silently } } @Override public int compareTo(TileRenderJob o) { if (priority != o.priority) { return o.priority - priority; } else { return (seq > o.seq) ? 1 : -1; } } private final long seq; private final Map> tileCache, dirtyTileCache; private final Point coords; private final TileProvider tileProvider; private final int effectiveZoom, priority; private final Image image; } /** * A listener for changes to a {@link TiledImageViewer} view. */ public interface ViewListener { /** * Invoked when the view has changed in one of these ways: *

  • The location has changed *
  • The zoom level has changed *
  • A {@link TileProvider} has been added, replaced or removed *
  • The offset of a tile provider has changed *
* @param source The tiled image viewer which has changed. */ void viewChanged(TiledImageViewer source); } class Overlay { Overlay(Component componentToTrack, String key, int x, BufferedImage image) { this.componentToTrack = componentToTrack; this.key = key; this.x = x; this.image = image; } final String key; final int x; final Component componentToTrack; final BufferedImage image; } public enum BackgroundImageMode {CENTRE, STRETCH, FIT, REPEAT, CENTRE_REPEAT, FIT_REPEAT} }




© 2015 - 2025 Weber Informatics LLC | Privacy Policy