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