001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.layer.geoimage;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.awt.Color;
007import java.awt.Dimension;
008import java.awt.FontMetrics;
009import java.awt.Graphics;
010import java.awt.Graphics2D;
011import java.awt.Image;
012import java.awt.Point;
013import java.awt.Rectangle;
014import java.awt.RenderingHints;
015import java.awt.event.ComponentEvent;
016import java.awt.event.MouseAdapter;
017import java.awt.event.MouseEvent;
018import java.awt.event.MouseWheelEvent;
019import java.awt.geom.Rectangle2D;
020import java.awt.image.BufferedImage;
021import java.io.IOException;
022import java.util.Objects;
023import java.util.concurrent.Future;
024
025import javax.swing.JComponent;
026import javax.swing.SwingUtilities;
027
028import org.openstreetmap.josm.data.imagery.street_level.IImageEntry;
029import org.openstreetmap.josm.data.imagery.street_level.Projections;
030import org.openstreetmap.josm.data.preferences.BooleanProperty;
031import org.openstreetmap.josm.data.preferences.DoubleProperty;
032import org.openstreetmap.josm.data.preferences.IntegerProperty;
033import org.openstreetmap.josm.gui.MainApplication;
034import org.openstreetmap.josm.gui.layer.AbstractMapViewPaintable;
035import org.openstreetmap.josm.gui.layer.geoimage.viewers.projections.IImageViewer;
036import org.openstreetmap.josm.gui.layer.geoimage.viewers.projections.ImageProjectionRegistry;
037import org.openstreetmap.josm.gui.layer.imagery.ImageryFilterSettings;
038import org.openstreetmap.josm.gui.layer.imagery.ImageryFilterSettings.FilterChangeListener;
039import org.openstreetmap.josm.gui.util.GuiHelper;
040import org.openstreetmap.josm.gui.util.imagery.Vector3D;
041import org.openstreetmap.josm.spi.preferences.Config;
042import org.openstreetmap.josm.spi.preferences.PreferenceChangeEvent;
043import org.openstreetmap.josm.spi.preferences.PreferenceChangedListener;
044import org.openstreetmap.josm.tools.Destroyable;
045import org.openstreetmap.josm.tools.ImageProcessor;
046import org.openstreetmap.josm.tools.JosmRuntimeException;
047import org.openstreetmap.josm.tools.Logging;
048import org.openstreetmap.josm.tools.Utils;
049
050/**
051 * GUI component to display an image (photograph).
052 *
053 * Offers basic mouse interaction (zoom, drag) and on-screen text.
054 * @since 2566
055 */
056public class ImageDisplay extends JComponent implements Destroyable, PreferenceChangedListener, FilterChangeListener {
057
058    /** The current image viewer */
059    private IImageViewer iImageViewer;
060
061    /** The file that is currently displayed */
062    private IImageEntry<?> entry;
063
064    /** The previous file that is currently displayed. Cleared on paint. Only used to help improve UI error information. */
065    private IImageEntry<?> oldEntry;
066
067    /** The image currently displayed */
068    private transient BufferedImage image;
069
070    /** The image currently displayed after applying {@link #imageProcessor} */
071    private transient BufferedImage processedImage;
072
073    /**
074     * Process the image before it is being displayed
075     */
076    private final ImageProcessor imageProcessor;
077
078    /** The image currently displayed */
079    private boolean errorLoading;
080
081    /** The rectangle (in image coordinates) of the image that is visible. This rectangle is calculated
082     * each time the zoom is modified */
083    private VisRect visibleRect;
084
085    /** When a selection is done, the rectangle of the selection (in image coordinates) */
086    private VisRect selectedRect;
087
088    private final ImgDisplayMouseListener imgMouseListener = new ImgDisplayMouseListener();
089
090    private String emptyText;
091    private String osdText;
092
093    private static final BooleanProperty AGPIFO_STYLE =
094        new BooleanProperty("geoimage.agpifo-style-drag-and-zoom", false);
095    private static int dragButton;
096    private static int zoomButton;
097
098    /** Alternative to mouse wheel zoom; esp. handy if no mouse wheel is present **/
099    private static final BooleanProperty ZOOM_ON_CLICK =
100        new BooleanProperty("geoimage.use-mouse-clicks-to-zoom", true);
101
102    /** Zoom factor when click or wheel zooming **/
103    private static final DoubleProperty ZOOM_STEP =
104        new DoubleProperty("geoimage.zoom-step-factor", 3 / 2.0);
105
106    /** Maximum zoom allowed **/
107    private static final DoubleProperty MAX_ZOOM =
108        new DoubleProperty("geoimage.maximum-zoom-scale", 2.0);
109
110    /** Maximum width (in pixels) for loading images **/
111    private static final IntegerProperty MAX_WIDTH =
112        new IntegerProperty("geoimage.maximum-width", 6000);
113
114    /** Show a background for the error text (may be hard on eyes) */
115    private static final BooleanProperty ERROR_MESSAGE_BACKGROUND = new BooleanProperty("geoimage.message.error.background", false);
116
117    private UpdateImageThread updateImageThreadInstance;
118
119    private class UpdateImageThread extends Thread {
120        private boolean restart;
121
122        @Override
123        public void run() {
124            updateProcessedImage();
125            if (restart) {
126                restart = false;
127                run();
128            }
129        }
130
131        public void restart() {
132            restart = true;
133            if (!isAlive()) {
134                restart = false;
135                updateImageThreadInstance = new UpdateImageThread();
136                updateImageThreadInstance.start();
137            }
138        }
139    }
140
141    @Override
142    public void preferenceChanged(PreferenceChangeEvent e) {
143        if (e == null ||
144            e.getKey().equals(AGPIFO_STYLE.getKey())) {
145            dragButton = AGPIFO_STYLE.get() ? 1 : 3;
146            zoomButton = dragButton == 1 ? 3 : 1;
147        }
148    }
149
150    /**
151     * Manage the visible rectangle of an image with full bounds stored in init.
152     * @since 13127
153     */
154    public static class VisRect extends Rectangle {
155        private final Rectangle init;
156
157        /** set when this {@code VisRect} is updated by a mouse drag operation and
158         * unset on mouse release **/
159        public boolean isDragUpdate;
160
161        /**
162         * Constructs a new {@code VisRect}.
163         * @param     x the specified X coordinate
164         * @param     y the specified Y coordinate
165         * @param     width  the width of the rectangle
166         * @param     height the height of the rectangle
167         */
168        public VisRect(int x, int y, int width, int height) {
169            super(x, y, width, height);
170            init = new Rectangle(this);
171        }
172
173        /**
174         * Constructs a new {@code VisRect}.
175         * @param     x the specified X coordinate
176         * @param     y the specified Y coordinate
177         * @param     width  the width of the rectangle
178         * @param     height the height of the rectangle
179         * @param     peer share full bounds with this peer {@code VisRect}
180         */
181        public VisRect(int x, int y, int width, int height, VisRect peer) {
182            super(x, y, width, height);
183            init = peer.init;
184        }
185
186        /**
187         * Constructs a new {@code VisRect} from another one.
188         * @param v rectangle to copy
189         */
190        public VisRect(VisRect v) {
191            super(v);
192            init = v.init;
193        }
194
195        /**
196         * Constructs a new empty {@code VisRect}.
197         */
198        public VisRect() {
199            this(0, 0, 0, 0);
200        }
201
202        public boolean isFullView() {
203            return init.equals(this);
204        }
205
206        public boolean isFullView1D() {
207            return (init.x == x && init.width == width)
208                || (init.y == y && init.height == height);
209        }
210
211        public void reset() {
212            setBounds(init);
213        }
214
215        public void checkRectPos() {
216            if (x < 0) {
217                x = 0;
218            }
219            if (y < 0) {
220                y = 0;
221            }
222            if (x + width > init.width) {
223                x = init.width - width;
224            }
225            if (y + height > init.height) {
226                y = init.height - height;
227            }
228        }
229
230        public void checkRectSize() {
231            if (width > init.width) {
232                width = init.width;
233            }
234            if (height > init.height) {
235                height = init.height;
236            }
237        }
238
239        public void checkPointInside(Point p) {
240            if (p.x < x) {
241                p.x = x;
242            }
243            if (p.x > x + width) {
244                p.x = x + width;
245            }
246            if (p.y < y) {
247                p.y = y;
248            }
249            if (p.y > y + height) {
250                p.y = y + height;
251            }
252        }
253
254        @Override
255        public int hashCode() {
256            return 31 * super.hashCode() + Objects.hash(init);
257        }
258
259        @Override
260        public boolean equals(Object obj) {
261            if (this == obj)
262                return true;
263            if (!super.equals(obj) || getClass() != obj.getClass())
264                return false;
265            VisRect other = (VisRect) obj;
266            return Objects.equals(init, other.init);
267        }
268    }
269
270    /** The thread that reads the images. */
271    protected class LoadImageRunnable implements Runnable {
272
273        private final IImageEntry<?> entry;
274
275        LoadImageRunnable(IImageEntry<?> entry) {
276            this.entry = entry;
277        }
278
279        @Override
280        public void run() {
281            try {
282                Dimension target = new Dimension(MAX_WIDTH.get(), MAX_WIDTH.get());
283                BufferedImage img = entry.read(target);
284                if (img == null) {
285                    synchronized (ImageDisplay.this) {
286                        errorLoading = true;
287                        ImageDisplay.this.repaint();
288                        return;
289                    }
290                }
291
292                int width = img.getWidth();
293                int height = img.getHeight();
294                entry.setWidth(width);
295                entry.setHeight(height);
296
297                synchronized (ImageDisplay.this) {
298                    if (this.entry != ImageDisplay.this.entry) {
299                        // The file has changed
300                        return;
301                    }
302
303                    ImageDisplay.this.image = img;
304                    updateProcessedImage();
305                    // This will clear the loading info box
306                    ImageDisplay.this.oldEntry = ImageDisplay.this.entry;
307                    visibleRect = getIImageViewer(entry).getDefaultVisibleRectangle(ImageDisplay.this, image);
308
309                    selectedRect = null;
310                    errorLoading = false;
311                }
312                ImageDisplay.this.repaint();
313            } catch (IOException ex) {
314                Logging.error(ex);
315            }
316        }
317    }
318
319    private class ImgDisplayMouseListener extends MouseAdapter {
320
321        private MouseEvent lastMouseEvent;
322        private Point mousePointInImg;
323
324        private boolean mouseIsDragging(MouseEvent e) {
325            return (dragButton == 1 && SwingUtilities.isLeftMouseButton(e)) ||
326                   (dragButton == 2 && SwingUtilities.isMiddleMouseButton(e)) ||
327                   (dragButton == 3 && SwingUtilities.isRightMouseButton(e));
328        }
329
330        private boolean mouseIsZoomSelecting(MouseEvent e) {
331            return (zoomButton == 1 && SwingUtilities.isLeftMouseButton(e)) ||
332                   (zoomButton == 2 && SwingUtilities.isMiddleMouseButton(e)) ||
333                   (zoomButton == 3 && SwingUtilities.isRightMouseButton(e));
334        }
335
336        private boolean isAtMaxZoom(Rectangle visibleRect) {
337            return (visibleRect.width == (int) (getSize().width / MAX_ZOOM.get()) ||
338                    visibleRect.height == (int) (getSize().height / MAX_ZOOM.get()));
339        }
340
341        private void mouseWheelMovedImpl(int x, int y, int rotation, boolean refreshMousePointInImg) {
342            IImageEntry<?> currentEntry;
343            IImageViewer imageViewer;
344            Image currentImage;
345            VisRect currentVisibleRect;
346
347            synchronized (ImageDisplay.this) {
348                currentEntry = ImageDisplay.this.entry;
349                currentImage = ImageDisplay.this.image;
350                currentVisibleRect = ImageDisplay.this.visibleRect;
351                imageViewer = ImageDisplay.this.iImageViewer;
352            }
353
354            selectedRect = null;
355
356            if (currentImage == null)
357                return;
358
359            // Calculate the mouse cursor position in image coordinates to center the zoom.
360            if (refreshMousePointInImg)
361                mousePointInImg = comp2imgCoord(currentVisibleRect, x, y, getSize());
362
363            // Apply the zoom to the visible rectangle in image coordinates
364            if (rotation > 0) {
365                currentVisibleRect.width = (int) (currentVisibleRect.width * ZOOM_STEP.get());
366                currentVisibleRect.height = (int) (currentVisibleRect.height * ZOOM_STEP.get());
367            } else {
368                currentVisibleRect.width = (int) (currentVisibleRect.width / ZOOM_STEP.get());
369                currentVisibleRect.height = (int) (currentVisibleRect.height / ZOOM_STEP.get());
370            }
371
372            // Check that the zoom doesn't exceed MAX_ZOOM:1
373            ensureMaxZoom(currentVisibleRect);
374
375            // The size of the visible rectangle is limited by the image size or the viewer implementation.
376            if (imageViewer != null) {
377                imageViewer.checkAndModifyVisibleRectSize(currentImage, currentVisibleRect);
378            } else {
379                currentVisibleRect.checkRectSize();
380            }
381
382            // Set the position of the visible rectangle, so that the mouse cursor doesn't move on the image.
383            Rectangle drawRect = calculateDrawImageRectangle(currentVisibleRect, getSize());
384            currentVisibleRect.x = mousePointInImg.x + ((drawRect.x - x) * currentVisibleRect.width) / drawRect.width;
385            currentVisibleRect.y = mousePointInImg.y + ((drawRect.y - y) * currentVisibleRect.height) / drawRect.height;
386
387            // The position is also limited by the image size
388            currentVisibleRect.checkRectPos();
389
390            synchronized (ImageDisplay.this) {
391                if (ImageDisplay.this.entry == currentEntry) {
392                    ImageDisplay.this.visibleRect = currentVisibleRect;
393                }
394            }
395            ImageDisplay.this.repaint();
396        }
397
398        /** Zoom in and out, trying to preserve the point of the image that was under the mouse cursor
399         * at the same place */
400        @Override
401        public void mouseWheelMoved(MouseWheelEvent e) {
402            boolean refreshMousePointInImg = false;
403
404            // To avoid issues when the user tries to zoom in on the image borders, this
405            // point is not recalculated as long as e occurs at roughly the same position.
406            if (lastMouseEvent == null || mousePointInImg == null ||
407                ((lastMouseEvent.getX()-e.getX())*(lastMouseEvent.getX()-e.getX())
408                +(lastMouseEvent.getY()-e.getY())*(lastMouseEvent.getY()-e.getY()) > 4*4)) {
409                lastMouseEvent = e;
410                refreshMousePointInImg = true;
411            }
412
413            mouseWheelMovedImpl(e.getX(), e.getY(), e.getWheelRotation(), refreshMousePointInImg);
414        }
415
416        /** Center the display on the point that has been clicked */
417        @Override
418        public void mouseClicked(MouseEvent e) {
419            // Move the center to the clicked point.
420            IImageEntry<?> currentEntry;
421            Image currentImage;
422            VisRect currentVisibleRect;
423
424            synchronized (ImageDisplay.this) {
425                currentEntry = ImageDisplay.this.entry;
426                currentImage = ImageDisplay.this.image;
427                currentVisibleRect = ImageDisplay.this.visibleRect;
428            }
429
430            if (currentImage == null)
431                return;
432
433            if (ZOOM_ON_CLICK.get()) {
434                // click notions are less coherent than wheel, refresh mousePointInImg on each click
435                lastMouseEvent = null;
436
437                if (mouseIsZoomSelecting(e) && !isAtMaxZoom(currentVisibleRect)) {
438                    // zoom in if clicked with the zoom button
439                    mouseWheelMovedImpl(e.getX(), e.getY(), -1, true);
440                    return;
441                }
442                if (mouseIsDragging(e)) {
443                    // zoom out if clicked with the drag button
444                    mouseWheelMovedImpl(e.getX(), e.getY(), 1, true);
445                    return;
446                }
447            }
448
449            // Calculate the translation to set the clicked point the center of the view.
450            Point click = comp2imgCoord(currentVisibleRect, e.getX(), e.getY(), getSize());
451            Point center = getCenterImgCoord(currentVisibleRect);
452
453            currentVisibleRect.x += click.x - center.x;
454            currentVisibleRect.y += click.y - center.y;
455
456            currentVisibleRect.checkRectPos();
457
458            synchronized (ImageDisplay.this) {
459                if (ImageDisplay.this.entry == currentEntry) {
460                    ImageDisplay.this.visibleRect = currentVisibleRect;
461                }
462            }
463            ImageDisplay.this.repaint();
464        }
465
466        /** Initialize the dragging, either with button 1 (simple dragging) or button 3 (selection of
467         * a picture part) */
468        @Override
469        public void mousePressed(MouseEvent e) {
470            Image currentImage;
471            VisRect currentVisibleRect;
472
473            synchronized (ImageDisplay.this) {
474                currentImage = ImageDisplay.this.image;
475                currentVisibleRect = ImageDisplay.this.visibleRect;
476            }
477
478            if (currentImage == null)
479                return;
480
481            selectedRect = null;
482
483            if (mouseIsDragging(e) || mouseIsZoomSelecting(e))
484                mousePointInImg = comp2imgCoord(currentVisibleRect, e.getX(), e.getY(), getSize());
485        }
486
487        @Override
488        public void mouseDragged(MouseEvent e) {
489            if (!mouseIsDragging(e) && !mouseIsZoomSelecting(e))
490                return;
491
492            IImageEntry<?> imageEntry;
493            Image currentImage;
494            VisRect currentVisibleRect;
495
496            synchronized (ImageDisplay.this) {
497                imageEntry = ImageDisplay.this.entry;
498                currentImage = ImageDisplay.this.image;
499                currentVisibleRect = ImageDisplay.this.visibleRect;
500            }
501
502            if (currentImage == null)
503                return;
504
505            if (mouseIsDragging(e) && mousePointInImg != null) {
506                Point p = comp2imgCoord(currentVisibleRect, e.getX(), e.getY(), getSize());
507                getIImageViewer(entry).mouseDragged(this.mousePointInImg, p, currentVisibleRect);
508                currentVisibleRect.checkRectPos();
509                synchronized (ImageDisplay.this) {
510                    if (ImageDisplay.this.entry == imageEntry) {
511                        ImageDisplay.this.visibleRect = currentVisibleRect;
512                    }
513                }
514                // We have to update the mousePointInImg for 360 image panning, as otherwise the panning never stops.
515                // This does not work well with the perspective viewer at this time (2021-08-26).
516                boolean is360panning = entry != null && Projections.EQUIRECTANGULAR == entry.getProjectionType();
517                if (is360panning) {
518                    this.mousePointInImg = p;
519                }
520                ImageDisplay.this.repaint();
521                if (is360panning) {
522                    // repaint direction arrow
523                    MainApplication.getLayerManager().getLayersOfType(GeoImageLayer.class).forEach(AbstractMapViewPaintable::invalidate);
524                }
525            }
526
527            if (mouseIsZoomSelecting(e) && mousePointInImg != null) {
528                Point p = comp2imgCoord(currentVisibleRect, e.getX(), e.getY(), getSize());
529                currentVisibleRect.checkPointInside(p);
530                VisRect selectedRectTemp = new VisRect(
531                        Math.min(p.x, mousePointInImg.x),
532                        Math.min(p.y, mousePointInImg.y),
533                        p.x < mousePointInImg.x ? mousePointInImg.x - p.x : p.x - mousePointInImg.x,
534                        p.y < mousePointInImg.y ? mousePointInImg.y - p.y : p.y - mousePointInImg.y,
535                        currentVisibleRect);
536                selectedRectTemp.checkRectSize();
537                selectedRectTemp.checkRectPos();
538                ImageDisplay.this.selectedRect = selectedRectTemp;
539                ImageDisplay.this.repaint();
540            }
541        }
542
543        @Override
544        public void mouseReleased(MouseEvent e) {
545            IImageEntry<?> currentEntry;
546            Image currentImage;
547            VisRect currentVisibleRect;
548
549            synchronized (ImageDisplay.this) {
550                currentEntry = ImageDisplay.this.entry;
551                currentImage = ImageDisplay.this.image;
552                currentVisibleRect = ImageDisplay.this.visibleRect;
553            }
554
555            if (currentImage == null)
556                return;
557
558            if (mouseIsDragging(e)) {
559                currentVisibleRect.isDragUpdate = false;
560            }
561
562            if (mouseIsZoomSelecting(e) && selectedRect != null) {
563                int oldWidth = selectedRect.width;
564                int oldHeight = selectedRect.height;
565
566                // Check that the zoom doesn't exceed MAX_ZOOM:1
567                ensureMaxZoom(selectedRect);
568
569                // Keep the center of the selection
570                if (selectedRect.width != oldWidth) {
571                    selectedRect.x -= (selectedRect.width - oldWidth) / 2;
572                }
573                if (selectedRect.height != oldHeight) {
574                    selectedRect.y -= (selectedRect.height - oldHeight) / 2;
575                }
576
577                selectedRect.checkRectSize();
578                selectedRect.checkRectPos();
579            }
580
581            synchronized (ImageDisplay.this) {
582                if (currentEntry == ImageDisplay.this.entry) {
583                    if (selectedRect == null) {
584                        ImageDisplay.this.visibleRect = currentVisibleRect;
585                    } else {
586                        ImageDisplay.this.visibleRect.setBounds(selectedRect);
587                        selectedRect = null;
588                    }
589                }
590            }
591            ImageDisplay.this.repaint();
592        }
593    }
594
595    /**
596     * Constructs a new {@code ImageDisplay} with no image processor.
597     */
598    public ImageDisplay() {
599        this(imageObject -> imageObject);
600    }
601
602    /**
603     * Constructs a new {@code ImageDisplay} with a given image processor.
604     * @param imageProcessor image processor
605     * @since 17740
606     */
607    public ImageDisplay(ImageProcessor imageProcessor) {
608        addMouseListener(imgMouseListener);
609        addMouseWheelListener(imgMouseListener);
610        addMouseMotionListener(imgMouseListener);
611        Config.getPref().addPreferenceChangeListener(this);
612        preferenceChanged(null);
613        this.imageProcessor = imageProcessor;
614        if (imageProcessor instanceof ImageryFilterSettings) {
615            ((ImageryFilterSettings) imageProcessor).addFilterChangeListener(this);
616        }
617    }
618
619    @Override
620    public void destroy() {
621        removeMouseListener(imgMouseListener);
622        removeMouseWheelListener(imgMouseListener);
623        removeMouseMotionListener(imgMouseListener);
624        Config.getPref().removePreferenceChangeListener(this);
625        if (imageProcessor instanceof ImageryFilterSettings) {
626            ((ImageryFilterSettings) imageProcessor).removeFilterChangeListener(this);
627        }
628    }
629
630    /**
631     * Sets a new source image to be displayed by this {@code ImageDisplay}.
632     * @param entry new source image
633     * @return a {@link Future} representing pending completion of the image loading task
634     * @since 18246 (signature)
635     */
636    public Future<?> setImage(IImageEntry<?> entry) {
637        LoadImageRunnable runnable = setImage0(entry);
638        return runnable != null ? MainApplication.worker.submit(runnable) : null;
639    }
640
641    protected LoadImageRunnable setImage0(IImageEntry<?> entry) {
642        synchronized (this) {
643            this.oldEntry = this.entry;
644            this.entry = entry;
645            if (entry == null) {
646                image = null;
647                updateProcessedImage();
648                this.oldEntry = null;
649            }
650            errorLoading = false;
651        }
652        repaint();
653        return entry != null ? new LoadImageRunnable(entry) : null;
654    }
655
656    /**
657     * Set the message displayed when there is no image to display.
658     * By default it display a simple No image
659     * @param emptyText the string to display
660     * @since 15333
661     */
662    public void setEmptyText(String emptyText) {
663        this.emptyText = emptyText;
664    }
665
666    /**
667     * Sets the On-Screen-Display text.
668     * @param text text to display on top of the image
669     */
670    public void setOsdText(String text) {
671        if (!text.equals(this.osdText)) {
672            this.osdText = text;
673            repaint();
674        }
675    }
676
677    @Override
678    public void filterChanged() {
679        if (updateImageThreadInstance != null) {
680            updateImageThreadInstance.restart();
681        } else {
682            updateImageThreadInstance = new UpdateImageThread();
683            updateImageThreadInstance.start();
684        }
685    }
686
687    private void updateProcessedImage() {
688        processedImage = image == null ? null : imageProcessor.process(image);
689        GuiHelper.runInEDT(this::repaint);
690    }
691
692    @Override
693    public void paintComponent(Graphics g) {
694        super.paintComponent(g);
695
696        IImageEntry<?> currentEntry;
697        IImageEntry<?> currentOldEntry;
698        IImageViewer currentImageViewer;
699        BufferedImage currentImage;
700        VisRect currentVisibleRect;
701        boolean currentErrorLoading;
702
703        synchronized (this) {
704            currentImage = this.processedImage;
705            currentEntry = this.entry;
706            currentOldEntry = this.oldEntry;
707            currentVisibleRect = this.visibleRect;
708            currentErrorLoading = this.errorLoading;
709        }
710
711        if (g instanceof Graphics2D) {
712            ((Graphics2D) g).setRenderingHint(RenderingHints.KEY_TEXT_ANTIALIASING, RenderingHints.VALUE_TEXT_ANTIALIAS_ON);
713        }
714
715        Dimension size = getSize();
716        // Draw the image first, then draw error information
717        if (currentImage != null && (currentEntry != null || currentOldEntry != null)) {
718            currentImageViewer = this.getIImageViewer(currentEntry);
719            Rectangle r = new Rectangle(currentVisibleRect);
720            Rectangle target = calculateDrawImageRectangle(currentVisibleRect, size);
721
722            currentImageViewer.paintImage(g, currentImage, target, r);
723            paintSelectedRect(g, target, currentVisibleRect, size);
724            if (currentErrorLoading && currentEntry != null) {
725                String loadingStr = tr("Error on file {0}", currentEntry.getDisplayName());
726                Rectangle2D noImageSize = g.getFontMetrics(g.getFont()).getStringBounds(loadingStr, g);
727                g.drawString(loadingStr, (int) ((size.width - noImageSize.getWidth()) / 2),
728                        (int) ((size.height - noImageSize.getHeight()) / 2));
729            }
730            paintOsdText(g);
731        }
732        paintErrorMessage(g, currentEntry, currentOldEntry, currentImage, currentErrorLoading, size);
733    }
734
735    /**
736     * Paint an error message
737     * @param g The graphics to paint on
738     * @param imageEntry The current image entry
739     * @param oldImageEntry The old image entry
740     * @param bufferedImage The image being painted
741     * @param currentErrorLoading If there was an error loading the image
742     * @param size The size of the component
743     */
744    private void paintErrorMessage(Graphics g, IImageEntry<?> imageEntry, IImageEntry<?> oldImageEntry,
745            BufferedImage bufferedImage, boolean currentErrorLoading, Dimension size) {
746        final String errorMessage;
747        // If the new entry is null, then there is no image.
748        if (imageEntry == null) {
749            if (emptyText == null) {
750                emptyText = tr("No image");
751            }
752            errorMessage = emptyText;
753        } else if (bufferedImage == null || !Objects.equals(imageEntry, oldImageEntry)) {
754            // The image is not necessarily null when loading anymore. If the oldEntry is not the same as the new entry,
755            // we are probably still loading the image. (oldEntry gets set to entry when the image finishes loading).
756            if (!currentErrorLoading) {
757                errorMessage = tr("Loading {0}", imageEntry.getDisplayName());
758            } else {
759                errorMessage = tr("Error on file {0}", imageEntry.getDisplayName());
760            }
761        } else {
762            errorMessage = null;
763        }
764        if (!Utils.isBlank(errorMessage)) {
765            Rectangle2D errorStringSize = g.getFontMetrics(g.getFont()).getStringBounds(errorMessage, g);
766            if (Boolean.TRUE.equals(ERROR_MESSAGE_BACKGROUND.get())) {
767                int height = g.getFontMetrics().getHeight();
768                int descender = g.getFontMetrics().getDescent();
769                g.setColor(getBackground());
770                int width = (int) (errorStringSize.getWidth() * 1);
771                // top-left of text
772                int tlx = (int) ((size.getWidth() - errorStringSize.getWidth()) / 2);
773                int tly = (int) ((size.getHeight() - 3 * errorStringSize.getHeight()) / 2 + descender);
774                g.fillRect(tlx, tly, width, height);
775            }
776
777            // lower-left of text
778            int llx = (int) ((size.width - errorStringSize.getWidth()) / 2);
779            int lly = (int) ((size.height - errorStringSize.getHeight()) / 2);
780            g.setColor(getForeground());
781            g.drawString(errorMessage, llx, lly);
782        }
783    }
784
785    /**
786     * Paint OSD text
787     * @param g The graphics to paint on
788     */
789    private void paintOsdText(Graphics g) {
790        if (osdText != null) {
791            FontMetrics metrics = g.getFontMetrics(g.getFont());
792            int ascent = metrics.getAscent();
793            Color bkground = new Color(255, 255, 255, 128);
794            int lastPos = 0;
795            int pos = osdText.indexOf('\n');
796            int x = 3;
797            int y = 3;
798            String line;
799            while (pos > 0) {
800                line = osdText.substring(lastPos, pos);
801                Rectangle2D lineSize = metrics.getStringBounds(line, g);
802                g.setColor(bkground);
803                g.fillRect(x, y, (int) lineSize.getWidth(), (int) lineSize.getHeight());
804                g.setColor(Color.black);
805                g.drawString(line, x, y + ascent);
806                y += (int) lineSize.getHeight();
807                lastPos = pos + 1;
808                pos = osdText.indexOf('\n', lastPos);
809            }
810
811            line = osdText.substring(lastPos);
812            Rectangle2D lineSize = g.getFontMetrics(g.getFont()).getStringBounds(line, g);
813            g.setColor(bkground);
814            g.fillRect(x, y, (int) lineSize.getWidth(), (int) lineSize.getHeight());
815            g.setColor(Color.black);
816            g.drawString(line, x, y + ascent);
817        }
818    }
819
820    /**
821     * Paint the selected rectangle
822     * @param g The graphics to paint on
823     * @param target The target area (i.e., the selection)
824     * @param visibleRectTemp The current visible rect
825     * @param size The size of the component
826     */
827    private void paintSelectedRect(Graphics g, Rectangle target, VisRect visibleRectTemp, Dimension size) {
828        if (selectedRect != null) {
829            Point topLeft = img2compCoord(visibleRectTemp, selectedRect.x, selectedRect.y, size);
830            Point bottomRight = img2compCoord(visibleRectTemp,
831                    selectedRect.x + selectedRect.width,
832                    selectedRect.y + selectedRect.height, size);
833            g.setColor(new Color(128, 128, 128, 180));
834            g.fillRect(target.x, target.y, target.width, topLeft.y - target.y);
835            g.fillRect(target.x, target.y, topLeft.x - target.x, target.height);
836            g.fillRect(bottomRight.x, target.y, target.x + target.width - bottomRight.x, target.height);
837            g.fillRect(target.x, bottomRight.y, target.width, target.y + target.height - bottomRight.y);
838            g.setColor(Color.black);
839            g.drawRect(topLeft.x, topLeft.y, bottomRight.x - topLeft.x, bottomRight.y - topLeft.y);
840        }
841    }
842
843    static Point img2compCoord(VisRect visibleRect, int xImg, int yImg, Dimension compSize) {
844        Rectangle drawRect = calculateDrawImageRectangle(visibleRect, compSize);
845        return new Point(drawRect.x + ((xImg - visibleRect.x) * drawRect.width) / visibleRect.width,
846                drawRect.y + ((yImg - visibleRect.y) * drawRect.height) / visibleRect.height);
847    }
848
849    static Point comp2imgCoord(VisRect visibleRect, int xComp, int yComp, Dimension compSize) {
850        Rectangle drawRect = calculateDrawImageRectangle(visibleRect, compSize);
851        Point p = new Point(
852                        ((xComp - drawRect.x) * visibleRect.width),
853                        ((yComp - drawRect.y) * visibleRect.height));
854        p.x += (((p.x % drawRect.width) << 1) >= drawRect.width) ? drawRect.width : 0;
855        p.y += (((p.y % drawRect.height) << 1) >= drawRect.height) ? drawRect.height : 0;
856        p.x = visibleRect.x + p.x / drawRect.width;
857        p.y = visibleRect.y + p.y / drawRect.height;
858        return p;
859    }
860
861    static Point getCenterImgCoord(Rectangle visibleRect) {
862        return new Point(visibleRect.x + visibleRect.width / 2,
863                         visibleRect.y + visibleRect.height / 2);
864    }
865
866    /**
867     * calculateDrawImageRectangle
868     *
869     * @param visibleRect the part of the image that should be drawn (in image coordinates)
870     * @param compSize the part of the component where the image should be drawn (in component coordinates)
871     * @return the part of compRect with the same width/height ratio as the image
872     */
873    static VisRect calculateDrawImageRectangle(VisRect visibleRect, Dimension compSize) {
874        return calculateDrawImageRectangle(visibleRect, new Rectangle(0, 0, compSize.width, compSize.height));
875    }
876
877    /**
878     * calculateDrawImageRectangle
879     *
880     * @param imgRect the part of the image that should be drawn (in image coordinates)
881     * @param compRect the part of the component where the image should be drawn (in component coordinates)
882     * @return the part of compRect with the same width/height ratio as the image
883     */
884    static VisRect calculateDrawImageRectangle(VisRect imgRect, Rectangle compRect) {
885        int x = 0;
886        int y = 0;
887        int w = compRect.width;
888        int h = compRect.height;
889
890        int wFact = w * imgRect.height;
891        int hFact = h * imgRect.width;
892        if (wFact != hFact) {
893            if (wFact > hFact) {
894                w = hFact / imgRect.height;
895                x = (compRect.width - w) / 2;
896            } else {
897                h = wFact / imgRect.width;
898                y = (compRect.height - h) / 2;
899            }
900        }
901
902        // overscan to prevent empty edges when zooming in to zoom scales > 2:1
903        if (w > imgRect.width && h > imgRect.height && !imgRect.isFullView1D() && wFact != hFact) {
904            if (wFact > hFact) {
905                w = compRect.width;
906                x = 0;
907                h = wFact / imgRect.width;
908                y = (compRect.height - h) / 2;
909            } else {
910                h = compRect.height;
911                y = 0;
912                w = hFact / imgRect.height;
913                x = (compRect.width - w) / 2;
914            }
915        }
916
917        return new VisRect(x + compRect.x, y + compRect.y, w, h, imgRect);
918    }
919
920    /**
921     * Make the current image either scale to fit inside this component,
922     * or show a portion of image (1:1), if the image size is larger than
923     * the component size.
924     */
925    public void zoomBestFitOrOne() {
926        IImageEntry<?> currentEntry;
927        Image currentImage;
928        VisRect currentVisibleRect;
929
930        synchronized (this) {
931            currentEntry = this.entry;
932            currentImage = this.image;
933            currentVisibleRect = this.visibleRect;
934        }
935
936        if (currentImage == null)
937            return;
938
939        if (currentVisibleRect.width != currentImage.getWidth(null) || currentVisibleRect.height != currentImage.getHeight(null)) {
940            // The display is not at best fit. => Zoom to best fit
941            currentVisibleRect.reset();
942        } else {
943            // The display is at best fit => zoom to 1:1
944            Point center = getCenterImgCoord(currentVisibleRect);
945            currentVisibleRect.setBounds(center.x - getWidth() / 2, center.y - getHeight() / 2,
946                    getWidth(), getHeight());
947            currentVisibleRect.checkRectSize();
948            currentVisibleRect.checkRectPos();
949        }
950
951        synchronized (this) {
952            if (this.entry == currentEntry) {
953                this.visibleRect = currentVisibleRect;
954            }
955        }
956        repaint();
957    }
958
959    /**
960     * Get the image viewer for an entry
961     * @param entry The entry to get the viewer for. May be {@code null}.
962     * @return The new image viewer, may be {@code null}
963     */
964    private IImageViewer getIImageViewer(IImageEntry<?> entry) {
965        IImageViewer imageViewer;
966        IImageEntry<?> imageEntry;
967        synchronized (this) {
968            imageViewer = this.iImageViewer;
969            imageEntry = entry == null ? this.entry : entry;
970        }
971        if (imageEntry == null || (imageViewer != null && imageViewer.getSupportedProjections().contains(imageEntry.getProjectionType()))) {
972            return imageViewer;
973        }
974        try {
975            imageViewer = ImageProjectionRegistry.getViewer(imageEntry.getProjectionType()).getConstructor().newInstance();
976        } catch (ReflectiveOperationException e) {
977            throw new JosmRuntimeException(e);
978        }
979        synchronized (this) {
980            if (imageEntry.equals(this.entry)) {
981                this.removeComponentListener(this.iImageViewer);
982                this.iImageViewer = imageViewer;
983                imageViewer.componentResized(new ComponentEvent(this, ComponentEvent.COMPONENT_RESIZED));
984                this.addComponentListener(this.iImageViewer);
985            }
986        }
987        return imageViewer;
988    }
989
990    /**
991     * Get the rotation in the image viewer for an entry
992     * @param entry The entry to get the rotation for. May be {@code null}.
993     * @return the current rotation in the image viewer, or {@code null}
994     * @since 18263
995     */
996    public Vector3D getRotation(IImageEntry<?> entry) {
997        return entry != null ? getIImageViewer(entry).getRotation() : null;
998    }
999
1000    /**
1001     * Ensure that a rectangle isn't zoomed in too much
1002     * @param rectangle The rectangle to get (typically the visible area)
1003     */
1004    private void ensureMaxZoom(final Rectangle rectangle) {
1005        if (rectangle.width < getSize().width / MAX_ZOOM.get()) {
1006            rectangle.width = (int) (getSize().width / MAX_ZOOM.get());
1007        }
1008        if (rectangle.height < getSize().height / MAX_ZOOM.get()) {
1009            rectangle.height = (int) (getSize().height / MAX_ZOOM.get());
1010        }
1011
1012        // Set the same ratio for the visible rectangle and the display area
1013        int hFact = rectangle.height * getSize().width;
1014        int wFact = rectangle.width * getSize().height;
1015        if (hFact > wFact) {
1016            rectangle.width = hFact / getSize().height;
1017        } else {
1018            rectangle.height = wFact / getSize().width;
1019        }
1020    }
1021
1022    /**
1023     * Update the visible rectangle (ensure zoom does not exceed specified values).
1024     * Specifically only visible for {@link IImageViewer} implementations.
1025     * @since 18246
1026     */
1027    public void updateVisibleRectangle() {
1028        final VisRect currentVisibleRect;
1029        final Image mouseImage;
1030        final IImageViewer iImageViewer;
1031        synchronized (this) {
1032            currentVisibleRect = this.visibleRect;
1033            mouseImage = this.image;
1034            iImageViewer = this.getIImageViewer(this.entry);
1035        }
1036        if (mouseImage != null && currentVisibleRect != null && iImageViewer != null) {
1037            final Image maxImageSize = iImageViewer.getMaxImageSize(this, mouseImage);
1038            final VisRect maxVisibleRect = new VisRect(0, 0, maxImageSize.getWidth(null), maxImageSize.getHeight(null));
1039            maxVisibleRect.setRect(currentVisibleRect);
1040            ensureMaxZoom(maxVisibleRect);
1041
1042            maxVisibleRect.checkRectSize();
1043            synchronized (this) {
1044                this.visibleRect = maxVisibleRect;
1045            }
1046        }
1047    }
1048}