001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.layer.geoimage;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005import static org.openstreetmap.josm.tools.I18n.trn;
006
007import java.awt.AlphaComposite;
008import java.awt.BasicStroke;
009import java.awt.Color;
010import java.awt.Composite;
011import java.awt.Dimension;
012import java.awt.Graphics2D;
013import java.awt.Image;
014import java.awt.Point;
015import java.awt.Rectangle;
016import java.awt.RenderingHints;
017import java.awt.event.MouseAdapter;
018import java.awt.event.MouseEvent;
019import java.awt.event.MouseMotionAdapter;
020import java.awt.image.BufferedImage;
021import java.io.File;
022import java.io.IOException;
023import java.util.ArrayList;
024import java.util.Arrays;
025import java.util.Collection;
026import java.util.HashSet;
027import java.util.LinkedHashSet;
028import java.util.LinkedList;
029import java.util.List;
030import java.util.Set;
031import java.util.concurrent.ExecutorService;
032import java.util.concurrent.Executors;
033
034import javax.swing.Action;
035import javax.swing.Icon;
036import javax.swing.JOptionPane;
037
038import org.openstreetmap.josm.actions.LassoModeAction;
039import org.openstreetmap.josm.actions.RenameLayerAction;
040import org.openstreetmap.josm.actions.mapmode.MapMode;
041import org.openstreetmap.josm.actions.mapmode.SelectAction;
042import org.openstreetmap.josm.data.Bounds;
043import org.openstreetmap.josm.data.ImageData;
044import org.openstreetmap.josm.data.ImageData.ImageDataUpdateListener;
045import org.openstreetmap.josm.data.gpx.GpxData;
046import org.openstreetmap.josm.data.gpx.WayPoint;
047import org.openstreetmap.josm.data.osm.visitor.BoundingXYVisitor;
048import org.openstreetmap.josm.gui.MainApplication;
049import org.openstreetmap.josm.gui.MapFrame;
050import org.openstreetmap.josm.gui.MapFrame.MapModeChangeListener;
051import org.openstreetmap.josm.gui.MapView;
052import org.openstreetmap.josm.gui.NavigatableComponent;
053import org.openstreetmap.josm.gui.PleaseWaitRunnable;
054import org.openstreetmap.josm.gui.dialogs.LayerListDialog;
055import org.openstreetmap.josm.gui.dialogs.LayerListPopup;
056import org.openstreetmap.josm.gui.io.importexport.JpgImporter;
057import org.openstreetmap.josm.gui.layer.AbstractModifiableLayer;
058import org.openstreetmap.josm.gui.layer.GpxLayer;
059import org.openstreetmap.josm.gui.layer.JumpToMarkerActions.JumpToMarkerLayer;
060import org.openstreetmap.josm.gui.layer.JumpToMarkerActions.JumpToNextMarker;
061import org.openstreetmap.josm.gui.layer.JumpToMarkerActions.JumpToPreviousMarker;
062import org.openstreetmap.josm.gui.layer.Layer;
063import org.openstreetmap.josm.gui.layer.MainLayerManager.ActiveLayerChangeListener;
064import org.openstreetmap.josm.tools.ImageProvider;
065import org.openstreetmap.josm.tools.Logging;
066import org.openstreetmap.josm.tools.Utils;
067
068/**
069 * Layer displaying geottaged pictures.
070 */
071public class GeoImageLayer extends AbstractModifiableLayer implements
072        JumpToMarkerLayer, NavigatableComponent.ZoomChangeListener, ImageDataUpdateListener {
073
074    private static List<Action> menuAdditions = new LinkedList<>();
075
076    private static volatile List<MapMode> supportedMapModes;
077
078    private final ImageData data;
079    GpxLayer gpxLayer;
080    GpxLayer gpxFauxLayer;
081
082    private final Icon icon = ImageProvider.get("dialogs/geoimage/photo-marker");
083    private final Icon selectedIcon = ImageProvider.get("dialogs/geoimage/photo-marker-selected");
084
085    boolean useThumbs;
086    private final ExecutorService thumbsLoaderExecutor =
087            Executors.newSingleThreadExecutor(Utils.newThreadFactory("thumbnail-loader-%d", Thread.MIN_PRIORITY));
088    private ThumbsLoader thumbsloader;
089    private boolean thumbsLoaderRunning;
090    volatile boolean thumbsLoaded;
091    private BufferedImage offscreenBuffer;
092    private boolean updateOffscreenBuffer = true;
093
094    private MouseAdapter mouseAdapter;
095    private MouseMotionAdapter mouseMotionAdapter;
096    private MapModeChangeListener mapModeListener;
097    private ActiveLayerChangeListener activeLayerChangeListener;
098
099    /** Mouse position where the last image was selected. */
100    private Point lastSelPos;
101    /** The mouse point */
102    private Point startPoint;
103
104    /**
105     * Image cycle mode flag.
106     * It is possible that a mouse button release triggers multiple mouseReleased() events.
107     * To prevent the cycling in such a case we wait for the next mouse button press event
108     * before it is cycled to the next image.
109     */
110    private boolean cycleModeArmed;
111
112    /**
113     * Constructs a new {@code GeoImageLayer}.
114     * @param data The list of images to display
115     * @param gpxLayer The associated GPX layer
116     */
117    public GeoImageLayer(final List<ImageEntry> data, GpxLayer gpxLayer) {
118        this(data, gpxLayer, null, false);
119    }
120
121    /**
122     * Constructs a new {@code GeoImageLayer}.
123     * @param data The list of images to display
124     * @param gpxLayer The associated GPX layer
125     * @param name Layer name
126     * @since 6392
127     */
128    public GeoImageLayer(final List<ImageEntry> data, GpxLayer gpxLayer, final String name) {
129        this(data, gpxLayer, name, false);
130    }
131
132    /**
133     * Constructs a new {@code GeoImageLayer}.
134     * @param data The list of images to display
135     * @param gpxLayer The associated GPX layer
136     * @param useThumbs Thumbnail display flag
137     * @since 6392
138     */
139    public GeoImageLayer(final List<ImageEntry> data, GpxLayer gpxLayer, boolean useThumbs) {
140        this(data, gpxLayer, null, useThumbs);
141    }
142
143    /**
144     * Constructs a new {@code GeoImageLayer}.
145     * @param data The list of images to display
146     * @param gpxLayer The associated GPX layer
147     * @param name Layer name
148     * @param useThumbs Thumbnail display flag
149     * @since 6392
150     */
151    public GeoImageLayer(final List<ImageEntry> data, GpxLayer gpxLayer, final String name, boolean useThumbs) {
152        super(name != null ? name : tr("Geotagged Images"));
153        this.data = new ImageData(data);
154        this.gpxLayer = gpxLayer;
155        this.useThumbs = useThumbs;
156        this.data.addImageDataUpdateListener(this);
157    }
158
159    private final class ImageMouseListener extends MouseAdapter {
160        private boolean isMapModeOk() {
161            MapMode mapMode = MainApplication.getMap().mapMode;
162            return mapMode == null || isSupportedMapMode(mapMode);
163        }
164
165        @Override
166        public void mousePressed(MouseEvent e) {
167            if (e.getButton() != MouseEvent.BUTTON1)
168                return;
169            if (isVisible() && isMapModeOk()) {
170                cycleModeArmed = true;
171                invalidate();
172                startPoint = e.getPoint();
173            }
174        }
175
176        @Override
177        public void mouseReleased(MouseEvent ev) {
178            if (ev.getButton() != MouseEvent.BUTTON1)
179                return;
180            if (!isVisible() || !isMapModeOk())
181                return;
182            if (!cycleModeArmed) {
183                return;
184            }
185
186            Rectangle hitBoxClick = new Rectangle((int) startPoint.getX() - 10, (int) startPoint.getY() - 10, 15, 15);
187            if (!hitBoxClick.contains(ev.getPoint())) {
188                return;
189            }
190
191            Point mousePos = ev.getPoint();
192            boolean cycle = cycleModeArmed && lastSelPos != null && lastSelPos.equals(mousePos);
193            final boolean isShift = (ev.getModifiersEx() & MouseEvent.SHIFT_DOWN_MASK) == MouseEvent.SHIFT_DOWN_MASK;
194            final boolean isCtrl = (ev.getModifiersEx() & MouseEvent.CTRL_DOWN_MASK) == MouseEvent.CTRL_DOWN_MASK;
195            int idx = getPhotoIdxUnderMouse(ev, cycle);
196            if (idx >= 0) {
197                lastSelPos = mousePos;
198                cycleModeArmed = false;
199                ImageEntry img = data.getImages().get(idx);
200                if (isShift) {
201                    if (isCtrl && !data.getSelectedImages().isEmpty()) {
202                        int idx2 = data.getImages().indexOf(data.getSelectedImages().get(data.getSelectedImages().size() - 1));
203                        int startIndex = Math.min(idx, idx2);
204                        int endIndex = Math.max(idx, idx2);
205                        for (int i = startIndex; i <= endIndex; i++) {
206                            data.addImageToSelection(data.getImages().get(i));
207                        }
208                    } else {
209                        if (data.isImageSelected(img)) {
210                            data.removeImageToSelection(img);
211                        } else {
212                            data.addImageToSelection(img);
213                        }
214                    }
215                } else {
216                    data.setSelectedImage(img);
217                }
218            }
219        }
220    }
221
222    /**
223     * Loads a set of images, while displaying a dialog that indicates what the plugin is currently doing.
224     * In facts, this object is instantiated with a list of files. These files may be JPEG files or
225     * directories. In case of directories, they are scanned to find all the images they contain.
226     * Then all the images that have be found are loaded as ImageEntry instances.
227     */
228    static final class Loader extends PleaseWaitRunnable {
229
230        private boolean canceled;
231        private GeoImageLayer layer;
232        private final Collection<File> selection;
233        private final Set<String> loadedDirectories = new HashSet<>();
234        private final Set<String> errorMessages;
235        private final GpxLayer gpxLayer;
236
237        Loader(Collection<File> selection, GpxLayer gpxLayer) {
238            super(tr("Extracting GPS locations from EXIF"));
239            this.selection = selection;
240            this.gpxLayer = gpxLayer;
241            errorMessages = new LinkedHashSet<>();
242        }
243
244        private void rememberError(String message) {
245            this.errorMessages.add(message);
246        }
247
248        @Override
249        protected void realRun() throws IOException {
250
251            progressMonitor.subTask(tr("Starting directory scan"));
252            Collection<File> files = new ArrayList<>();
253            try {
254                addRecursiveFiles(files, selection);
255            } catch (IllegalStateException e) {
256                Logging.debug(e);
257                rememberError(e.getMessage());
258            }
259
260            if (canceled)
261                return;
262            progressMonitor.subTask(tr("Read photos..."));
263            progressMonitor.setTicksCount(files.size());
264
265            // read the image files
266            List<ImageEntry> entries = new ArrayList<>(files.size());
267
268            for (File f : files) {
269
270                if (canceled) {
271                    break;
272                }
273
274                progressMonitor.subTask(tr("Reading {0}...", f.getName()));
275                progressMonitor.worked(1);
276
277                ImageEntry e = new ImageEntry(f);
278                e.extractExif();
279                entries.add(e);
280            }
281            layer = new GeoImageLayer(entries, gpxLayer);
282            files.clear();
283        }
284
285        private void addRecursiveFiles(Collection<File> files, Collection<File> sel) {
286            boolean nullFile = false;
287
288            for (File f : sel) {
289
290                if (canceled) {
291                    break;
292                }
293
294                if (f == null) {
295                    nullFile = true;
296
297                } else if (f.isDirectory()) {
298                    String canonical = null;
299                    try {
300                        canonical = f.getCanonicalPath();
301                    } catch (IOException e) {
302                        Logging.error(e);
303                        rememberError(tr("Unable to get canonical path for directory {0}\n",
304                                f.getAbsolutePath()));
305                    }
306
307                    if (canonical == null || loadedDirectories.contains(canonical)) {
308                        continue;
309                    } else {
310                        loadedDirectories.add(canonical);
311                    }
312
313                    File[] children = f.listFiles(JpgImporter.FILE_FILTER_WITH_FOLDERS);
314                    if (children != null) {
315                        progressMonitor.subTask(tr("Scanning directory {0}", f.getPath()));
316                        addRecursiveFiles(files, Arrays.asList(children));
317                    } else {
318                        rememberError(tr("Error while getting files from directory {0}\n", f.getPath()));
319                    }
320
321                } else {
322                    files.add(f);
323                }
324            }
325
326            if (nullFile) {
327                throw new IllegalStateException(tr("One of the selected files was null"));
328            }
329        }
330
331        private String formatErrorMessages() {
332            StringBuilder sb = new StringBuilder();
333            sb.append("<html>");
334            if (errorMessages.size() == 1) {
335                sb.append(Utils.escapeReservedCharactersHTML(errorMessages.iterator().next()));
336            } else {
337                sb.append(Utils.joinAsHtmlUnorderedList(errorMessages));
338            }
339            sb.append("</html>");
340            return sb.toString();
341        }
342
343        @Override protected void finish() {
344            if (!errorMessages.isEmpty()) {
345                JOptionPane.showMessageDialog(
346                        MainApplication.getMainFrame(),
347                        formatErrorMessages(),
348                        tr("Error"),
349                        JOptionPane.ERROR_MESSAGE
350                        );
351            }
352            if (layer != null) {
353                MainApplication.getLayerManager().addLayer(layer);
354
355                if (!canceled && !layer.getImageData().getImages().isEmpty()) {
356                    boolean noGeotagFound = true;
357                    for (ImageEntry e : layer.getImageData().getImages()) {
358                        if (e.getPos() != null) {
359                            noGeotagFound = false;
360                        }
361                    }
362                    if (noGeotagFound) {
363                        new CorrelateGpxWithImages(layer).actionPerformed(null);
364                    }
365                }
366            }
367        }
368
369        @Override protected void cancel() {
370            canceled = true;
371        }
372    }
373
374    /**
375     * Create a GeoImageLayer asynchronously
376     * @param files the list of image files to display
377     * @param gpxLayer the gpx layer
378     */
379    public static void create(Collection<File> files, GpxLayer gpxLayer) {
380        MainApplication.worker.execute(new Loader(files, gpxLayer));
381    }
382
383    @Override
384    public Icon getIcon() {
385        return ImageProvider.get("dialogs/geoimage", ImageProvider.ImageSizes.LAYER);
386    }
387
388    /**
389     * Register actions on the layer
390     * @param addition the action to be added
391     */
392    public static void registerMenuAddition(Action addition) {
393        menuAdditions.add(addition);
394    }
395
396    @Override
397    public Action[] getMenuEntries() {
398
399        List<Action> entries = new ArrayList<>();
400        entries.add(LayerListDialog.getInstance().createShowHideLayerAction());
401        entries.add(LayerListDialog.getInstance().createDeleteLayerAction());
402        entries.add(LayerListDialog.getInstance().createMergeLayerAction(this));
403        entries.add(new RenameLayerAction(null, this));
404        entries.add(SeparatorLayerAction.INSTANCE);
405        entries.add(new CorrelateGpxWithImages(this));
406        entries.add(new ShowThumbnailAction(this));
407        if (!menuAdditions.isEmpty()) {
408            entries.add(SeparatorLayerAction.INSTANCE);
409            entries.addAll(menuAdditions);
410        }
411        entries.add(SeparatorLayerAction.INSTANCE);
412        entries.add(new JumpToNextMarker(this));
413        entries.add(new JumpToPreviousMarker(this));
414        entries.add(SeparatorLayerAction.INSTANCE);
415        entries.add(new LayerListPopup.InfoAction(this));
416
417        return entries.toArray(new Action[0]);
418
419    }
420
421    /**
422     * Prepare the string that is displayed if layer information is requested.
423     * @return String with layer information
424     */
425    private String infoText() {
426        int tagged = 0;
427        int newdata = 0;
428        int n = data.getImages().size();
429        for (ImageEntry e : data.getImages()) {
430            if (e.getPos() != null) {
431                tagged++;
432            }
433            if (e.hasNewGpsData()) {
434                newdata++;
435            }
436        }
437        return "<html>"
438                + trn("{0} image loaded.", "{0} images loaded.", n, n)
439                + ' ' + trn("{0} was found to be GPS tagged.", "{0} were found to be GPS tagged.", tagged, tagged)
440                + (newdata > 0 ? "<br>" + trn("{0} has updated GPS data.", "{0} have updated GPS data.", newdata, newdata) : "")
441                + "</html>";
442    }
443
444    @Override public Object getInfoComponent() {
445        return infoText();
446    }
447
448    @Override
449    public String getToolTipText() {
450        return infoText();
451    }
452
453    /**
454     * Determines if data managed by this layer has been modified.  That is
455     * the case if one image has modified GPS data.
456     * @return {@code true} if data has been modified; {@code false}, otherwise
457     */
458    @Override
459    public boolean isModified() {
460        return this.data.isModified();
461    }
462
463    @Override
464    public boolean isMergable(Layer other) {
465        return other instanceof GeoImageLayer;
466    }
467
468    @Override
469    public void mergeFrom(Layer from) {
470        if (!(from instanceof GeoImageLayer))
471            throw new IllegalArgumentException("not a GeoImageLayer: " + from);
472        GeoImageLayer l = (GeoImageLayer) from;
473
474        // Stop to load thumbnails on both layers.  Thumbnail loading will continue the next time
475        // the layer is painted.
476        stopLoadThumbs();
477        l.stopLoadThumbs();
478
479        this.data.mergeFrom(l.getImageData());
480
481        setName(l.getName());
482        thumbsLoaded &= l.thumbsLoaded;
483    }
484
485    private static Dimension scaledDimension(Image thumb) {
486        final double d = MainApplication.getMap().mapView.getDist100Pixel();
487        final double size = 10 /*meter*/;     /* size of the photo on the map */
488        double s = size * 100 /*px*/ / d;
489
490        final double sMin = ThumbsLoader.minSize;
491        final double sMax = ThumbsLoader.maxSize;
492
493        if (s < sMin) {
494            s = sMin;
495        }
496        if (s > sMax) {
497            s = sMax;
498        }
499        final double f = s / sMax;  /* scale factor */
500
501        if (thumb == null)
502            return null;
503
504        return new Dimension(
505                (int) Math.round(f * thumb.getWidth(null)),
506                (int) Math.round(f * thumb.getHeight(null)));
507    }
508
509    /**
510     * Paint one image.
511     * @param e Image to be painted
512     * @param mv Map view
513     * @param clip Bounding rectangle of the current clipping area
514     * @param tempG Temporary offscreen buffer
515     */
516    private void paintImage(ImageEntry e, MapView mv, Rectangle clip, Graphics2D tempG) {
517        if (e.getPos() == null) {
518            return;
519        }
520        Point p = mv.getPoint(e.getPos());
521        if (e.hasThumbnail()) {
522            Dimension d = scaledDimension(e.getThumbnail());
523            if (d != null) {
524                Rectangle target = new Rectangle(p.x - d.width / 2, p.y - d.height / 2, d.width, d.height);
525                if (clip.intersects(target)) {
526                    tempG.drawImage(e.getThumbnail(), target.x, target.y, target.width, target.height, null);
527                }
528            }
529        } else { // thumbnail not loaded yet
530            icon.paintIcon(mv, tempG,
531                p.x - icon.getIconWidth() / 2,
532                p.y - icon.getIconHeight() / 2);
533        }
534    }
535
536    @Override
537    public void paint(Graphics2D g, MapView mv, Bounds bounds) {
538        int width = mv.getWidth();
539        int height = mv.getHeight();
540        Rectangle clip = g.getClipBounds();
541        if (useThumbs) {
542            if (!thumbsLoaded) {
543                startLoadThumbs();
544            }
545
546            if (null == offscreenBuffer || offscreenBuffer.getWidth() != width  // reuse the old buffer if possible
547                    || offscreenBuffer.getHeight() != height) {
548                offscreenBuffer = new BufferedImage(width, height,
549                        BufferedImage.TYPE_INT_ARGB);
550                updateOffscreenBuffer = true;
551            }
552
553            if (updateOffscreenBuffer) {
554                Graphics2D tempG = offscreenBuffer.createGraphics();
555                tempG.setColor(new Color(0, 0, 0, 0));
556                Composite saveComp = tempG.getComposite();
557                tempG.setComposite(AlphaComposite.Clear);   // remove the old images
558                tempG.fillRect(0, 0, width, height);
559                tempG.setComposite(saveComp);
560
561                for (ImageEntry e : data.getImages()) {
562                    paintImage(e, mv, clip, tempG);
563                }
564                for (ImageEntry img: this.data.getSelectedImages()) {
565                    // Make sure the selected image is on top in case multiple images overlap.
566                    paintImage(img, mv, clip, tempG);
567                }
568                updateOffscreenBuffer = false;
569            }
570            g.drawImage(offscreenBuffer, 0, 0, null);
571        } else {
572            for (ImageEntry e : data.getImages()) {
573                if (e.getPos() == null) {
574                    continue;
575                }
576                Point p = mv.getPoint(e.getPos());
577                icon.paintIcon(mv, g,
578                        p.x - icon.getIconWidth() / 2,
579                        p.y - icon.getIconHeight() / 2);
580            }
581        }
582
583        for (ImageEntry e: data.getSelectedImages()) {
584            if (e != null && e.getPos() != null) {
585                Point p = mv.getPoint(e.getPos());
586
587                int imgWidth;
588                int imgHeight;
589                if (useThumbs && e.hasThumbnail()) {
590                    Dimension d = scaledDimension(e.getThumbnail());
591                    if (d != null) {
592                        imgWidth = d.width;
593                        imgHeight = d.height;
594                    } else {
595                        imgWidth = -1;
596                        imgHeight = -1;
597                    }
598                } else {
599                    imgWidth = selectedIcon.getIconWidth();
600                    imgHeight = selectedIcon.getIconHeight();
601                }
602
603                if (e.getExifImgDir() != null) {
604                    // Multiplier must be larger than sqrt(2)/2=0.71.
605                    double arrowlength = Math.max(25, Math.max(imgWidth, imgHeight) * 0.85);
606                    double arrowwidth = arrowlength / 1.4;
607
608                    double dir = e.getExifImgDir();
609                    // Rotate 90 degrees CCW
610                    double headdir = (dir < 90) ? dir + 270 : dir - 90;
611                    double leftdir = (headdir < 90) ? headdir + 270 : headdir - 90;
612                    double rightdir = (headdir > 270) ? headdir - 270 : headdir + 90;
613
614                    double ptx = p.x + Math.cos(Utils.toRadians(headdir)) * arrowlength;
615                    double pty = p.y + Math.sin(Utils.toRadians(headdir)) * arrowlength;
616
617                    double ltx = p.x + Math.cos(Utils.toRadians(leftdir)) * arrowwidth/2;
618                    double lty = p.y + Math.sin(Utils.toRadians(leftdir)) * arrowwidth/2;
619
620                    double rtx = p.x + Math.cos(Utils.toRadians(rightdir)) * arrowwidth/2;
621                    double rty = p.y + Math.sin(Utils.toRadians(rightdir)) * arrowwidth/2;
622
623                    g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
624                    g.setColor(new Color(255, 255, 255, 192));
625                    int[] xar = {(int) ltx, (int) ptx, (int) rtx, (int) ltx};
626                    int[] yar = {(int) lty, (int) pty, (int) rty, (int) lty};
627                    g.fillPolygon(xar, yar, 4);
628                    g.setColor(Color.black);
629                    g.setStroke(new BasicStroke(1.2f));
630                    g.drawPolyline(xar, yar, 3);
631                }
632
633                if (useThumbs && e.hasThumbnail()) {
634                    g.setColor(new Color(128, 0, 0, 122));
635                    g.fillRect(p.x - imgWidth / 2, p.y - imgHeight / 2, imgWidth, imgHeight);
636                } else {
637                    selectedIcon.paintIcon(mv, g,
638                            p.x - imgWidth / 2,
639                            p.y - imgHeight / 2);
640                }
641            }
642        }
643    }
644
645    @Override
646    public void visitBoundingBox(BoundingXYVisitor v) {
647        for (ImageEntry e : data.getImages()) {
648            v.visit(e.getPos());
649        }
650    }
651
652    /**
653     * Show current photo on map and in image viewer.
654     */
655    public void showCurrentPhoto() {
656        if (data.getSelectedImage() != null) {
657            clearOtherCurrentPhotos();
658        }
659        updateBufferAndRepaint();
660    }
661
662    /**
663     * Check if the position of the mouse event is within the rectangle of the photo icon or thumbnail.
664     * @param idx the image index
665     * @param evt Mouse event
666     * @return {@code true} if the photo matches the mouse position, {@code false} otherwise
667     */
668    private boolean isPhotoIdxUnderMouse(int idx, MouseEvent evt) {
669        ImageEntry img = data.getImages().get(idx);
670        if (img.getPos() != null) {
671            Point imgCenter = MainApplication.getMap().mapView.getPoint(img.getPos());
672            Rectangle imgRect;
673            if (useThumbs && img.hasThumbnail()) {
674                Dimension imgDim = scaledDimension(img.getThumbnail());
675                if (imgDim != null) {
676                    imgRect = new Rectangle(imgCenter.x - imgDim.width / 2,
677                                            imgCenter.y - imgDim.height / 2,
678                                            imgDim.width, imgDim.height);
679                } else {
680                    imgRect = null;
681                }
682            } else {
683                imgRect = new Rectangle(imgCenter.x - icon.getIconWidth() / 2,
684                                        imgCenter.y - icon.getIconHeight() / 2,
685                                        icon.getIconWidth(), icon.getIconHeight());
686            }
687            if (imgRect != null && imgRect.contains(evt.getPoint())) {
688                return true;
689            }
690        }
691        return false;
692    }
693
694    /**
695     * Returns index of the image that matches the position of the mouse event.
696     * @param evt    Mouse event
697     * @param cycle  Set to {@code true} to cycle through the photos at the
698     *               current mouse position if multiple icons or thumbnails overlap.
699     *               If set to {@code false} the topmost photo will be used.
700     * @return       Image index at mouse position, range 0 .. size-1,
701     *               or {@code -1} if there is no image at the mouse position
702     */
703    private int getPhotoIdxUnderMouse(MouseEvent evt, boolean cycle) {
704        ImageEntry selectedImage = data.getSelectedImage();
705        int selectedIndex = data.getImages().indexOf(selectedImage);
706
707        if (cycle && selectedImage != null) {
708            // Cycle loop is forward as that is the natural order.
709            // Loop 1: One after current photo up to last one.
710            for (int idx = selectedIndex + 1; idx < data.getImages().size(); ++idx) {
711                if (isPhotoIdxUnderMouse(idx, evt)) {
712                    return idx;
713                }
714            }
715            // Loop 2: First photo up to current one.
716            for (int idx = 0; idx <= selectedIndex; ++idx) {
717                if (isPhotoIdxUnderMouse(idx, evt)) {
718                    return idx;
719                }
720            }
721        } else {
722            // Check for current photo first, i.e. keep it selected if it is under the mouse.
723            if (selectedImage != null && isPhotoIdxUnderMouse(selectedIndex, evt)) {
724                return selectedIndex;
725            }
726            // Loop from last to first to prefer topmost image.
727            for (int idx = data.getImages().size() - 1; idx >= 0; --idx) {
728                if (isPhotoIdxUnderMouse(idx, evt)) {
729                    return idx;
730                }
731            }
732        }
733        return -1;
734    }
735
736    /**
737     * Returns index of the image that matches the position of the mouse event.
738     * The topmost photo is picked if multiple icons or thumbnails overlap.
739     * @param evt Mouse event
740     * @return Image index at mouse position, range 0 .. size-1,
741     *         or {@code -1} if there is no image at the mouse position
742     */
743    private int getPhotoIdxUnderMouse(MouseEvent evt) {
744        return getPhotoIdxUnderMouse(evt, false);
745    }
746
747    /**
748     * Returns the image that matches the position of the mouse event.
749     * The topmost photo is picked of multiple icons or thumbnails overlap.
750     * @param evt Mouse event
751     * @return Image at mouse position, or {@code null} if there is no image at the mouse position
752     * @since 6392
753     */
754    public ImageEntry getPhotoUnderMouse(MouseEvent evt) {
755        int idx = getPhotoIdxUnderMouse(evt);
756        if (idx >= 0) {
757            return data.getImages().get(idx);
758        } else {
759            return null;
760        }
761    }
762
763    /**
764     * Clears the currentPhoto of the other GeoImageLayer's. Otherwise there could be multiple selected photos.
765     */
766    private void clearOtherCurrentPhotos() {
767        for (GeoImageLayer layer:
768                 MainApplication.getLayerManager().getLayersOfType(GeoImageLayer.class)) {
769            if (layer != this) {
770                layer.getImageData().clearSelectedImage();
771            }
772        }
773    }
774
775    /**
776     * Registers a map mode for which the functionality of this layer should be available.
777     * @param mapMode Map mode to be registered
778     * @since 6392
779     */
780    public static void registerSupportedMapMode(MapMode mapMode) {
781        if (supportedMapModes == null) {
782            supportedMapModes = new ArrayList<>();
783        }
784        supportedMapModes.add(mapMode);
785    }
786
787    /**
788     * Determines if the functionality of this layer is available in
789     * the specified map mode. {@link SelectAction} and {@link LassoModeAction} are supported by default,
790     * other map modes can be registered.
791     * @param mapMode Map mode to be checked
792     * @return {@code true} if the map mode is supported,
793     *         {@code false} otherwise
794     */
795    private static boolean isSupportedMapMode(MapMode mapMode) {
796        if (mapMode instanceof SelectAction || mapMode instanceof LassoModeAction) {
797            return true;
798        }
799        if (supportedMapModes != null) {
800            for (MapMode supmmode: supportedMapModes) {
801                if (mapMode == supmmode) {
802                    return true;
803                }
804            }
805        }
806        return false;
807    }
808
809    @Override
810    public void hookUpMapView() {
811        mouseAdapter = new ImageMouseListener();
812
813        mouseMotionAdapter = new MouseMotionAdapter() {
814            @Override
815            public void mouseMoved(MouseEvent evt) {
816                lastSelPos = null;
817            }
818
819            @Override
820            public void mouseDragged(MouseEvent evt) {
821                lastSelPos = null;
822            }
823        };
824
825        mapModeListener = (oldMapMode, newMapMode) -> {
826            MapView mapView = MainApplication.getMap().mapView;
827            if (newMapMode == null || isSupportedMapMode(newMapMode)) {
828                mapView.addMouseListener(mouseAdapter);
829                mapView.addMouseMotionListener(mouseMotionAdapter);
830            } else {
831                mapView.removeMouseListener(mouseAdapter);
832                mapView.removeMouseMotionListener(mouseMotionAdapter);
833            }
834        };
835
836        MapFrame.addMapModeChangeListener(mapModeListener);
837        mapModeListener.mapModeChange(null, MainApplication.getMap().mapMode);
838
839        activeLayerChangeListener = e -> {
840            if (MainApplication.getLayerManager().getActiveLayer() == this) {
841                // only in select mode it is possible to click the images
842                MainApplication.getMap().selectSelectTool(false);
843            }
844        };
845        MainApplication.getLayerManager().addActiveLayerChangeListener(activeLayerChangeListener);
846
847        MapFrame map = MainApplication.getMap();
848        if (map.getToggleDialog(ImageViewerDialog.class) == null) {
849            ImageViewerDialog.createInstance();
850            map.addToggleDialog(ImageViewerDialog.getInstance());
851        }
852    }
853
854    @Override
855    public synchronized void destroy() {
856        super.destroy();
857        stopLoadThumbs();
858        MapView mapView = MainApplication.getMap().mapView;
859        mapView.removeMouseListener(mouseAdapter);
860        mapView.removeMouseMotionListener(mouseMotionAdapter);
861        MapView.removeZoomChangeListener(this);
862        MapFrame.removeMapModeChangeListener(mapModeListener);
863        MainApplication.getLayerManager().removeActiveLayerChangeListener(activeLayerChangeListener);
864        data.removeImageDataUpdateListener(this);
865    }
866
867    @Override
868    public LayerPainter attachToMapView(MapViewEvent event) {
869        MapView.addZoomChangeListener(this);
870        return new CompatibilityModeLayerPainter() {
871            @Override
872            public void detachFromMapView(MapViewEvent event) {
873                MapView.removeZoomChangeListener(GeoImageLayer.this);
874            }
875        };
876    }
877
878    @Override
879    public void zoomChanged() {
880        updateBufferAndRepaint();
881    }
882
883    /**
884     * Start to load thumbnails.
885     */
886    public synchronized void startLoadThumbs() {
887        if (useThumbs && !thumbsLoaded && !thumbsLoaderRunning) {
888            stopLoadThumbs();
889            thumbsloader = new ThumbsLoader(this);
890            thumbsLoaderExecutor.submit(thumbsloader);
891            thumbsLoaderRunning = true;
892        }
893    }
894
895    /**
896     * Stop to load thumbnails.
897     *
898     * Can be called at any time to make sure that the
899     * thumbnail loader is stopped.
900     */
901    public synchronized void stopLoadThumbs() {
902        if (thumbsloader != null) {
903            thumbsloader.stop = true;
904        }
905        thumbsLoaderRunning = false;
906    }
907
908    /**
909     * Called to signal that the loading of thumbnails has finished.
910     *
911     * Usually called from {@link ThumbsLoader} in another thread.
912     */
913    public void thumbsLoaded() {
914        thumbsLoaded = true;
915    }
916
917    /**
918     * Marks the offscreen buffer to be updated.
919     */
920    public void updateBufferAndRepaint() {
921        updateOffscreenBuffer = true;
922        invalidate();
923    }
924
925    /**
926     * Get list of images in layer.
927     * @return List of images in layer
928     */
929    public List<ImageEntry> getImages() {
930        return new ArrayList<>(data.getImages());
931    }
932
933    /**
934     * Returns the image data store being used by this layer
935     * @return imageData
936     * @since 14590
937     */
938    public ImageData getImageData() {
939        return data;
940    }
941
942    /**
943     * Returns the associated GPX layer.
944     * @return The associated GPX layer
945     */
946    public GpxLayer getGpxLayer() {
947        return gpxLayer;
948    }
949
950    /**
951     * Returns a faux GPX layer built from the images or the associated GPX layer.
952     * @return A faux GPX layer or the associated GPX layer
953     * @since 14802
954     */
955    public synchronized GpxLayer getFauxGpxLayer() {
956        if (gpxLayer != null) return getGpxLayer();
957        if (gpxFauxLayer == null) {
958            GpxData gpxData = new GpxData();
959            List<ImageEntry> imageList = data.getImages();
960            for (ImageEntry image : imageList) {
961                WayPoint twaypoint = new WayPoint(image.getPos());
962                gpxData.addWaypoint(twaypoint);
963            }
964            gpxFauxLayer = new GpxLayer(gpxData);
965        }
966        return gpxFauxLayer;
967    }
968
969    @Override
970    public void jumpToNextMarker() {
971        data.selectNextImage();
972    }
973
974    @Override
975    public void jumpToPreviousMarker() {
976        data.selectPreviousImage();
977    }
978
979    /**
980     * Returns the current thumbnail display status.
981     * {@code true}: thumbnails are displayed, {@code false}: an icon is displayed instead of thumbnails.
982     * @return Current thumbnail display status
983     * @since 6392
984     */
985    public boolean isUseThumbs() {
986        return useThumbs;
987    }
988
989    /**
990     * Enables or disables the display of thumbnails.  Does not update the display.
991     * @param useThumbs New thumbnail display status
992     * @since 6392
993     */
994    public void setUseThumbs(boolean useThumbs) {
995        this.useThumbs = useThumbs;
996        if (useThumbs && !thumbsLoaded) {
997            startLoadThumbs();
998        } else if (!useThumbs) {
999            stopLoadThumbs();
1000        }
1001        invalidate();
1002    }
1003
1004    @Override
1005    public void selectedImageChanged(ImageData data) {
1006        showCurrentPhoto();
1007    }
1008
1009    @Override
1010    public void imageDataUpdated(ImageData data) {
1011        updateBufferAndRepaint();
1012    }
1013
1014    @Override
1015    public String getChangesetSourceTag() {
1016        return getName();
1017    }
1018}