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