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, i.e. remove select marker, and optionally repaint.
699     * @param repaint Repaint flag
700     * @deprecated Use {@link ImageData#clearSelectedImage}
701     * @since 6392
702     */
703    @Deprecated
704    public void clearCurrentPhoto(boolean repaint) {
705        data.clearSelectedImage();
706        if (repaint) {
707            updateBufferAndRepaint();
708        }
709    }
710
711    /**
712     * Clears the currentPhoto of the other GeoImageLayer's. Otherwise there could be multiple selected photos.
713     */
714    private void clearOtherCurrentPhotos() {
715        for (GeoImageLayer layer:
716                 MainApplication.getLayerManager().getLayersOfType(GeoImageLayer.class)) {
717            if (layer != this) {
718                layer.getImageData().clearSelectedImage();
719            }
720        }
721    }
722
723    /**
724     * Registers a map mode for which the functionality of this layer should be available.
725     * @param mapMode Map mode to be registered
726     * @since 6392
727     */
728    public static void registerSupportedMapMode(MapMode mapMode) {
729        if (supportedMapModes == null) {
730            supportedMapModes = new ArrayList<>();
731        }
732        supportedMapModes.add(mapMode);
733    }
734
735    /**
736     * Determines if the functionality of this layer is available in
737     * the specified map mode. {@link SelectAction} and {@link LassoModeAction} are supported by default,
738     * other map modes can be registered.
739     * @param mapMode Map mode to be checked
740     * @return {@code true} if the map mode is supported,
741     *         {@code false} otherwise
742     */
743    private static boolean isSupportedMapMode(MapMode mapMode) {
744        if (mapMode instanceof SelectAction || mapMode instanceof LassoModeAction) {
745            return true;
746        }
747        if (supportedMapModes != null) {
748            for (MapMode supmmode: supportedMapModes) {
749                if (mapMode == supmmode) {
750                    return true;
751                }
752            }
753        }
754        return false;
755    }
756
757    @Override
758    public void hookUpMapView() {
759        mouseAdapter = new MouseAdapter() {
760            private boolean isMapModeOk() {
761                MapMode mapMode = MainApplication.getMap().mapMode;
762                return mapMode == null || isSupportedMapMode(mapMode);
763            }
764
765            @Override
766            public void mousePressed(MouseEvent e) {
767                if (e.getButton() != MouseEvent.BUTTON1)
768                    return;
769                if (isVisible() && isMapModeOk()) {
770                    cycleModeArmed = true;
771                    invalidate();
772                }
773            }
774
775            @Override
776            public void mouseReleased(MouseEvent ev) {
777                if (ev.getButton() != MouseEvent.BUTTON1)
778                    return;
779                if (!isVisible() || !isMapModeOk())
780                    return;
781
782                Point mousePos = ev.getPoint();
783                boolean cycle = cycleModeArmed && lastSelPos != null && lastSelPos.equals(mousePos);
784                int idx = getPhotoIdxUnderMouse(ev, cycle);
785                if (idx >= 0) {
786                    lastSelPos = mousePos;
787                    cycleModeArmed = false;
788                    data.setSelectedImage(data.getImages().get(idx));
789                }
790            }
791        };
792
793        mouseMotionAdapter = new MouseMotionAdapter() {
794            @Override
795            public void mouseMoved(MouseEvent evt) {
796                lastSelPos = null;
797            }
798
799            @Override
800            public void mouseDragged(MouseEvent evt) {
801                lastSelPos = null;
802            }
803        };
804
805        mapModeListener = (oldMapMode, newMapMode) -> {
806            MapView mapView = MainApplication.getMap().mapView;
807            if (newMapMode == null || isSupportedMapMode(newMapMode)) {
808                mapView.addMouseListener(mouseAdapter);
809                mapView.addMouseMotionListener(mouseMotionAdapter);
810            } else {
811                mapView.removeMouseListener(mouseAdapter);
812                mapView.removeMouseMotionListener(mouseMotionAdapter);
813            }
814        };
815
816        MapFrame.addMapModeChangeListener(mapModeListener);
817        mapModeListener.mapModeChange(null, MainApplication.getMap().mapMode);
818
819        activeLayerChangeListener = e -> {
820            if (MainApplication.getLayerManager().getActiveLayer() == this) {
821                // only in select mode it is possible to click the images
822                MainApplication.getMap().selectSelectTool(false);
823            }
824        };
825        MainApplication.getLayerManager().addActiveLayerChangeListener(activeLayerChangeListener);
826
827        MapFrame map = MainApplication.getMap();
828        if (map.getToggleDialog(ImageViewerDialog.class) == null) {
829            ImageViewerDialog.createInstance();
830            map.addToggleDialog(ImageViewerDialog.getInstance());
831        }
832    }
833
834    @Override
835    public synchronized void destroy() {
836        super.destroy();
837        stopLoadThumbs();
838        MapView mapView = MainApplication.getMap().mapView;
839        mapView.removeMouseListener(mouseAdapter);
840        mapView.removeMouseMotionListener(mouseMotionAdapter);
841        MapView.removeZoomChangeListener(this);
842        MapFrame.removeMapModeChangeListener(mapModeListener);
843        MainApplication.getLayerManager().removeActiveLayerChangeListener(activeLayerChangeListener);
844        data.removeImageDataUpdateListener(this);
845    }
846
847    @Override
848    public LayerPainter attachToMapView(MapViewEvent event) {
849        MapView.addZoomChangeListener(this);
850        return new CompatibilityModeLayerPainter() {
851            @Override
852            public void detachFromMapView(MapViewEvent event) {
853                MapView.removeZoomChangeListener(GeoImageLayer.this);
854            }
855        };
856    }
857
858    @Override
859    public void zoomChanged() {
860        updateBufferAndRepaint();
861    }
862
863    /**
864     * Start to load thumbnails.
865     */
866    public synchronized void startLoadThumbs() {
867        if (useThumbs && !thumbsLoaded && !thumbsLoaderRunning) {
868            stopLoadThumbs();
869            thumbsloader = new ThumbsLoader(this);
870            thumbsLoaderExecutor.submit(thumbsloader);
871            thumbsLoaderRunning = true;
872        }
873    }
874
875    /**
876     * Stop to load thumbnails.
877     *
878     * Can be called at any time to make sure that the
879     * thumbnail loader is stopped.
880     */
881    public synchronized void stopLoadThumbs() {
882        if (thumbsloader != null) {
883            thumbsloader.stop = true;
884        }
885        thumbsLoaderRunning = false;
886    }
887
888    /**
889     * Called to signal that the loading of thumbnails has finished.
890     *
891     * Usually called from {@link ThumbsLoader} in another thread.
892     */
893    public void thumbsLoaded() {
894        thumbsLoaded = true;
895    }
896
897    /**
898     * Marks the offscreen buffer to be updated.
899     */
900    public void updateBufferAndRepaint() {
901        updateOffscreenBuffer = true;
902        invalidate();
903    }
904
905    /**
906     * Get list of images in layer.
907     * @return List of images in layer
908     */
909    public List<ImageEntry> getImages() {
910        return new ArrayList<>(data.getImages());
911    }
912
913    /**
914     * Returns the image data store being used by this layer
915     * @return imageData
916     * @since 14590
917     */
918    public ImageData getImageData() {
919        return data;
920    }
921
922    /**
923     * Returns the associated GPX layer.
924     * @return The associated GPX layer
925     */
926    public GpxLayer getGpxLayer() {
927        return gpxLayer;
928    }
929
930    /**
931     * Returns a faux GPX layer built from the images or the associated GPX layer.
932     * @return A faux GPX layer or the associated GPX layer
933     * @since 14802
934     */
935    public synchronized GpxLayer getFauxGpxLayer() {
936        if (gpxLayer != null) return getGpxLayer();
937        if (gpxFauxLayer == null) {
938            GpxData gpxData = new GpxData();
939            List<ImageEntry> imageList = data.getImages();
940            for (ImageEntry image : imageList) {
941                WayPoint twaypoint = new WayPoint(image.getPos());
942                gpxData.addWaypoint(twaypoint);
943            }
944            gpxFauxLayer = new GpxLayer(gpxData);
945        }
946        return gpxFauxLayer;
947    }
948
949    @Override
950    public void jumpToNextMarker() {
951        data.selectNextImage();
952    }
953
954    @Override
955    public void jumpToPreviousMarker() {
956        data.selectPreviousImage();
957    }
958
959    /**
960     * Returns the current thumbnail display status.
961     * {@code true}: thumbnails are displayed, {@code false}: an icon is displayed instead of thumbnails.
962     * @return Current thumbnail display status
963     * @since 6392
964     */
965    public boolean isUseThumbs() {
966        return useThumbs;
967    }
968
969    /**
970     * Enables or disables the display of thumbnails.  Does not update the display.
971     * @param useThumbs New thumbnail display status
972     * @since 6392
973     */
974    public void setUseThumbs(boolean useThumbs) {
975        this.useThumbs = useThumbs;
976        if (useThumbs && !thumbsLoaded) {
977            startLoadThumbs();
978        } else if (!useThumbs) {
979            stopLoadThumbs();
980        }
981        invalidate();
982    }
983
984    @Override
985    public void selectedImageChanged(ImageData data) {
986        showCurrentPhoto();
987    }
988
989    @Override
990    public void imageDataUpdated(ImageData data) {
991        updateBufferAndRepaint();
992    }
993}