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