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.image.BufferedImage;
020import java.beans.PropertyChangeEvent;
021import java.beans.PropertyChangeListener;
022import java.io.File;
023import java.io.IOException;
024import java.util.ArrayList;
025import java.util.Arrays;
026import java.util.Collection;
027import java.util.Collections;
028import java.util.HashSet;
029import java.util.LinkedHashSet;
030import java.util.LinkedList;
031import java.util.List;
032import java.util.Set;
033import java.util.concurrent.ExecutorService;
034import java.util.concurrent.Executors;
035
036import javax.swing.Action;
037import javax.swing.Icon;
038import javax.swing.JLabel;
039import javax.swing.JOptionPane;
040import javax.swing.SwingConstants;
041
042import org.openstreetmap.josm.Main;
043import org.openstreetmap.josm.actions.LassoModeAction;
044import org.openstreetmap.josm.actions.RenameLayerAction;
045import org.openstreetmap.josm.actions.mapmode.MapMode;
046import org.openstreetmap.josm.actions.mapmode.SelectAction;
047import org.openstreetmap.josm.data.Bounds;
048import org.openstreetmap.josm.data.osm.visitor.BoundingXYVisitor;
049import org.openstreetmap.josm.gui.ExtendedDialog;
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.layer.AbstractModifiableLayer;
059import org.openstreetmap.josm.gui.layer.GpxLayer;
060import org.openstreetmap.josm.gui.layer.JumpToMarkerActions.JumpToMarkerLayer;
061import org.openstreetmap.josm.gui.layer.JumpToMarkerActions.JumpToNextMarker;
062import org.openstreetmap.josm.gui.layer.JumpToMarkerActions.JumpToPreviousMarker;
063import org.openstreetmap.josm.gui.layer.Layer;
064import org.openstreetmap.josm.gui.layer.LayerManager.LayerAddEvent;
065import org.openstreetmap.josm.gui.layer.LayerManager.LayerChangeListener;
066import org.openstreetmap.josm.gui.layer.LayerManager.LayerOrderChangeEvent;
067import org.openstreetmap.josm.gui.layer.LayerManager.LayerRemoveEvent;
068import org.openstreetmap.josm.gui.util.GuiHelper;
069import org.openstreetmap.josm.io.JpgImporter;
070import org.openstreetmap.josm.tools.ImageProvider;
071import org.openstreetmap.josm.tools.Utils;
072
073/**
074 * Layer displaying geottaged pictures.
075 */
076public class GeoImageLayer extends AbstractModifiableLayer implements PropertyChangeListener, JumpToMarkerLayer {
077
078    private static List<Action> menuAdditions = new LinkedList<>();
079
080    private static volatile List<MapMode> supportedMapModes;
081
082    List<ImageEntry> data;
083    GpxLayer gpxLayer;
084
085    private final Icon icon = ImageProvider.get("dialogs/geoimage/photo-marker");
086    private final Icon selectedIcon = ImageProvider.get("dialogs/geoimage/photo-marker-selected");
087
088    private int currentPhoto = -1;
089
090    boolean useThumbs;
091    private final ExecutorService thumbsLoaderExecutor =
092            Executors.newSingleThreadExecutor(Utils.newThreadFactory("thumbnail-loader-%d", Thread.MIN_PRIORITY));
093    private ThumbsLoader thumbsloader;
094    private boolean thumbsLoaderRunning;
095    volatile boolean thumbsLoaded;
096    private BufferedImage offscreenBuffer;
097    boolean updateOffscreenBuffer = true;
098
099    private MouseAdapter mouseAdapter;
100    private MapModeChangeListener mapModeListener;
101
102    /**
103     * Constructs a new {@code GeoImageLayer}.
104     * @param data The list of images to display
105     * @param gpxLayer The associated GPX layer
106     */
107    public GeoImageLayer(final List<ImageEntry> data, GpxLayer gpxLayer) {
108        this(data, gpxLayer, null, false);
109    }
110
111    /**
112     * Constructs a new {@code GeoImageLayer}.
113     * @param data The list of images to display
114     * @param gpxLayer The associated GPX layer
115     * @param name Layer name
116     * @since 6392
117     */
118    public GeoImageLayer(final List<ImageEntry> data, GpxLayer gpxLayer, final String name) {
119        this(data, gpxLayer, name, false);
120    }
121
122    /**
123     * Constructs a new {@code GeoImageLayer}.
124     * @param data The list of images to display
125     * @param gpxLayer The associated GPX layer
126     * @param useThumbs Thumbnail display flag
127     * @since 6392
128     */
129    public GeoImageLayer(final List<ImageEntry> data, GpxLayer gpxLayer, boolean useThumbs) {
130        this(data, gpxLayer, null, useThumbs);
131    }
132
133    /**
134     * Constructs a new {@code GeoImageLayer}.
135     * @param data The list of images to display
136     * @param gpxLayer The associated GPX layer
137     * @param name Layer name
138     * @param useThumbs Thumbnail display flag
139     * @since 6392
140     */
141    public GeoImageLayer(final List<ImageEntry> data, GpxLayer gpxLayer, final String name, boolean useThumbs) {
142        super(name != null ? name : tr("Geotagged Images"));
143        if (data != null) {
144            Collections.sort(data);
145        }
146        this.data = data;
147        this.gpxLayer = gpxLayer;
148        this.useThumbs = useThumbs;
149    }
150
151    /**
152     * Loads a set of images, while displaying a dialog that indicates what the plugin is currently doing.
153     * In facts, this object is instantiated with a list of files. These files may be JPEG files or
154     * directories. In case of directories, they are scanned to find all the images they contain.
155     * Then all the images that have be found are loaded as ImageEntry instances.
156     */
157    static final class Loader extends PleaseWaitRunnable {
158
159        private boolean canceled;
160        private GeoImageLayer layer;
161        private final Collection<File> selection;
162        private final Set<String> loadedDirectories = new HashSet<>();
163        private final Set<String> errorMessages;
164        private final GpxLayer gpxLayer;
165
166        Loader(Collection<File> selection, GpxLayer gpxLayer) {
167            super(tr("Extracting GPS locations from EXIF"));
168            this.selection = selection;
169            this.gpxLayer = gpxLayer;
170            errorMessages = new LinkedHashSet<>();
171        }
172
173        private void rememberError(String message) {
174            this.errorMessages.add(message);
175        }
176
177        @Override
178        protected void realRun() throws IOException {
179
180            progressMonitor.subTask(tr("Starting directory scan"));
181            Collection<File> files = new ArrayList<>();
182            try {
183                addRecursiveFiles(files, selection);
184            } catch (IllegalStateException e) {
185                Main.debug(e);
186                rememberError(e.getMessage());
187            }
188
189            if (canceled)
190                return;
191            progressMonitor.subTask(tr("Read photos..."));
192            progressMonitor.setTicksCount(files.size());
193
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                        Main.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(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                        Main.parent,
279                        formatErrorMessages(),
280                        tr("Error"),
281                        JOptionPane.ERROR_MESSAGE
282                        );
283            }
284            if (layer != null) {
285                Main.getLayerManager().addLayer(layer);
286
287                if (!canceled && layer.data != null && !layer.data.isEmpty()) {
288                    boolean noGeotagFound = true;
289                    for (ImageEntry e : layer.data) {
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    public static void create(Collection<File> files, GpxLayer gpxLayer) {
307        Main.worker.execute(new Loader(files, gpxLayer));
308    }
309
310    @Override
311    public Icon getIcon() {
312        return ImageProvider.get("dialogs/geoimage");
313    }
314
315    public static void registerMenuAddition(Action addition) {
316        menuAdditions.add(addition);
317    }
318
319    @Override
320    public Action[] getMenuEntries() {
321
322        List<Action> entries = new ArrayList<>();
323        entries.add(LayerListDialog.getInstance().createShowHideLayerAction());
324        entries.add(LayerListDialog.getInstance().createDeleteLayerAction());
325        entries.add(LayerListDialog.getInstance().createMergeLayerAction(this));
326        entries.add(new RenameLayerAction(null, this));
327        entries.add(SeparatorLayerAction.INSTANCE);
328        entries.add(new CorrelateGpxWithImages(this));
329        entries.add(new ShowThumbnailAction(this));
330        if (!menuAdditions.isEmpty()) {
331            entries.add(SeparatorLayerAction.INSTANCE);
332            entries.addAll(menuAdditions);
333        }
334        entries.add(SeparatorLayerAction.INSTANCE);
335        entries.add(new JumpToNextMarker(this));
336        entries.add(new JumpToPreviousMarker(this));
337        entries.add(SeparatorLayerAction.INSTANCE);
338        entries.add(new LayerListPopup.InfoAction(this));
339
340        return entries.toArray(new Action[entries.size()]);
341
342    }
343
344    /**
345     * Prepare the string that is displayed if layer information is requested.
346     * @return String with layer information
347     */
348    private String infoText() {
349        int tagged = 0;
350        int newdata = 0;
351        int n = 0;
352        if (data != null) {
353            n = data.size();
354            for (ImageEntry e : data) {
355                if (e.getPos() != null) {
356                    tagged++;
357                }
358                if (e.hasNewGpsData()) {
359                    newdata++;
360                }
361            }
362        }
363        return "<html>"
364                + trn("{0} image loaded.", "{0} images loaded.", n, n)
365                + ' ' + trn("{0} was found to be GPS tagged.", "{0} were found to be GPS tagged.", tagged, tagged)
366                + (newdata > 0 ? "<br>" + trn("{0} has updated GPS data.", "{0} have updated GPS data.", newdata, newdata) : "")
367                + "</html>";
368    }
369
370    @Override public Object getInfoComponent() {
371        return infoText();
372    }
373
374    @Override
375    public String getToolTipText() {
376        return infoText();
377    }
378
379    /**
380     * Determines if data managed by this layer has been modified.  That is
381     * the case if one image has modified GPS data.
382     * @return {@code true} if data has been modified; {@code false}, otherwise
383     */
384    @Override
385    public boolean isModified() {
386        if (data != null) {
387            for (ImageEntry e : data) {
388                if (e.hasNewGpsData()) {
389                    return true;
390                }
391            }
392        }
393        return false;
394    }
395
396    @Override
397    public boolean isMergable(Layer other) {
398        return other instanceof GeoImageLayer;
399    }
400
401    @Override
402    public void mergeFrom(Layer from) {
403        GeoImageLayer l = (GeoImageLayer) from;
404
405        // Stop to load thumbnails on both layers.  Thumbnail loading will continue the next time
406        // the layer is painted.
407        stopLoadThumbs();
408        l.stopLoadThumbs();
409
410        final ImageEntry selected = l.data != null && l.currentPhoto >= 0 ? l.data.get(l.currentPhoto) : null;
411
412        if (l.data != null) {
413            data.addAll(l.data);
414        }
415        Collections.sort(data);
416
417        // Supress the double photos.
418        if (data.size() > 1) {
419            ImageEntry cur;
420            ImageEntry prev = data.get(data.size() - 1);
421            for (int i = data.size() - 2; i >= 0; i--) {
422                cur = data.get(i);
423                if (cur.getFile().equals(prev.getFile())) {
424                    data.remove(i);
425                } else {
426                    prev = cur;
427                }
428            }
429        }
430
431        if (selected != null && !data.isEmpty()) {
432            GuiHelper.runInEDTAndWait(() -> {
433                for (int i = 0; i < data.size(); i++) {
434                    if (selected.equals(data.get(i))) {
435                        currentPhoto = i;
436                        ImageViewerDialog.showImage(this, data.get(i));
437                        break;
438                    }
439                }
440            });
441        }
442
443        setName(l.getName());
444        thumbsLoaded &= l.thumbsLoaded;
445    }
446
447    private static Dimension scaledDimension(Image thumb) {
448        final double d = Main.map.mapView.getDist100Pixel();
449        final double size = 10 /*meter*/;     /* size of the photo on the map */
450        double s = size * 100 /*px*/ / d;
451
452        final double sMin = ThumbsLoader.minSize;
453        final double sMax = ThumbsLoader.maxSize;
454
455        if (s < sMin) {
456            s = sMin;
457        }
458        if (s > sMax) {
459            s = sMax;
460        }
461        final double f = s / sMax;  /* scale factor */
462
463        if (thumb == null)
464            return null;
465
466        return new Dimension(
467                (int) Math.round(f * thumb.getWidth(null)),
468                (int) Math.round(f * thumb.getHeight(null)));
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                if (data != null) {
497                    for (ImageEntry e : data) {
498                        if (e.getPos() == null) {
499                            continue;
500                        }
501                        Point p = mv.getPoint(e.getPos());
502                        if (e.hasThumbnail()) {
503                            Dimension d = scaledDimension(e.getThumbnail());
504                            Rectangle target = new Rectangle(p.x - d.width / 2, p.y - d.height / 2, d.width, d.height);
505                            if (clip.intersects(target)) {
506                                tempG.drawImage(e.getThumbnail(), target.x, target.y, target.width, target.height, null);
507                            }
508                        } else { // thumbnail not loaded yet
509                            icon.paintIcon(mv, tempG,
510                                    p.x - icon.getIconWidth() / 2,
511                                    p.y - icon.getIconHeight() / 2);
512                        }
513                    }
514                }
515                updateOffscreenBuffer = false;
516            }
517            g.drawImage(offscreenBuffer, 0, 0, null);
518        } else if (data != null) {
519            for (ImageEntry e : data) {
520                if (e.getPos() == null) {
521                    continue;
522                }
523                Point p = mv.getPoint(e.getPos());
524                icon.paintIcon(mv, g,
525                        p.x - icon.getIconWidth() / 2,
526                        p.y - icon.getIconHeight() / 2);
527            }
528        }
529
530        if (currentPhoto >= 0 && currentPhoto < data.size()) {
531            ImageEntry e = data.get(currentPhoto);
532
533            if (e.getPos() != null) {
534                Point p = mv.getPoint(e.getPos());
535
536                int imgWidth;
537                int imgHeight;
538                if (useThumbs && e.hasThumbnail()) {
539                    Dimension d = scaledDimension(e.getThumbnail());
540                    imgWidth = d.width;
541                    imgHeight = d.height;
542                } else {
543                    imgWidth = selectedIcon.getIconWidth();
544                    imgHeight = selectedIcon.getIconHeight();
545                }
546
547                if (e.getExifImgDir() != null) {
548                    // Multiplier must be larger than sqrt(2)/2=0.71.
549                    double arrowlength = Math.max(25, Math.max(imgWidth, imgHeight) * 0.85);
550                    double arrowwidth = arrowlength / 1.4;
551
552                    double dir = e.getExifImgDir();
553                    // Rotate 90 degrees CCW
554                    double headdir = (dir < 90) ? dir + 270 : dir - 90;
555                    double leftdir = (headdir < 90) ? headdir + 270 : headdir - 90;
556                    double rightdir = (headdir > 270) ? headdir - 270 : headdir + 90;
557
558                    double ptx = p.x + Math.cos(Math.toRadians(headdir)) * arrowlength;
559                    double pty = p.y + Math.sin(Math.toRadians(headdir)) * arrowlength;
560
561                    double ltx = p.x + Math.cos(Math.toRadians(leftdir)) * arrowwidth/2;
562                    double lty = p.y + Math.sin(Math.toRadians(leftdir)) * arrowwidth/2;
563
564                    double rtx = p.x + Math.cos(Math.toRadians(rightdir)) * arrowwidth/2;
565                    double rty = p.y + Math.sin(Math.toRadians(rightdir)) * arrowwidth/2;
566
567                    g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
568                    g.setColor(new Color(255, 255, 255, 192));
569                    int[] xar = {(int) ltx, (int) ptx, (int) rtx, (int) ltx};
570                    int[] yar = {(int) lty, (int) pty, (int) rty, (int) lty};
571                    g.fillPolygon(xar, yar, 4);
572                    g.setColor(Color.black);
573                    g.setStroke(new BasicStroke(1.2f));
574                    g.drawPolyline(xar, yar, 3);
575                }
576
577                if (useThumbs && e.hasThumbnail()) {
578                    g.setColor(new Color(128, 0, 0, 122));
579                    g.fillRect(p.x - imgWidth / 2, p.y - imgHeight / 2, imgWidth, imgHeight);
580                } else {
581                    selectedIcon.paintIcon(mv, g,
582                            p.x - imgWidth / 2,
583                            p.y - imgHeight / 2);
584
585                }
586            }
587        }
588    }
589
590    @Override
591    public void visitBoundingBox(BoundingXYVisitor v) {
592        for (ImageEntry e : data) {
593            v.visit(e.getPos());
594        }
595    }
596
597    /**
598     * Shows next photo.
599     */
600    public void showNextPhoto() {
601        if (data != null && !data.isEmpty()) {
602            currentPhoto++;
603            if (currentPhoto >= data.size()) {
604                currentPhoto = data.size() - 1;
605            }
606            ImageViewerDialog.showImage(this, data.get(currentPhoto));
607        } else {
608            currentPhoto = -1;
609        }
610        Main.map.repaint();
611    }
612
613    /**
614     * Shows previous photo.
615     */
616    public void showPreviousPhoto() {
617        if (data != null && !data.isEmpty()) {
618            currentPhoto--;
619            if (currentPhoto < 0) {
620                currentPhoto = 0;
621            }
622            ImageViewerDialog.showImage(this, data.get(currentPhoto));
623        } else {
624            currentPhoto = -1;
625        }
626        Main.map.repaint();
627    }
628
629    /**
630     * Shows first photo.
631     */
632    public void showFirstPhoto() {
633        if (data != null && !data.isEmpty()) {
634            currentPhoto = 0;
635            ImageViewerDialog.showImage(this, data.get(currentPhoto));
636        } else {
637            currentPhoto = -1;
638        }
639        Main.map.repaint();
640    }
641
642    /**
643     * Shows last photo.
644     */
645    public void showLastPhoto() {
646        if (data != null && !data.isEmpty()) {
647            currentPhoto = data.size() - 1;
648            ImageViewerDialog.showImage(this, data.get(currentPhoto));
649        } else {
650            currentPhoto = -1;
651        }
652        Main.map.repaint();
653    }
654
655    public void checkPreviousNextButtons() {
656        ImageViewerDialog.setNextEnabled(data != null && currentPhoto < data.size() - 1);
657        ImageViewerDialog.setPreviousEnabled(currentPhoto > 0);
658    }
659
660    public void removeCurrentPhoto() {
661        if (data != null && !data.isEmpty() && currentPhoto >= 0 && currentPhoto < data.size()) {
662            data.remove(currentPhoto);
663            if (currentPhoto >= data.size()) {
664                currentPhoto = data.size() - 1;
665            }
666            if (currentPhoto >= 0) {
667                ImageViewerDialog.showImage(this, data.get(currentPhoto));
668            } else {
669                ImageViewerDialog.showImage(this, null);
670            }
671            updateOffscreenBuffer = true;
672            Main.map.repaint();
673        }
674    }
675
676    public void removeCurrentPhotoFromDisk() {
677        ImageEntry toDelete;
678        if (data != null && !data.isEmpty() && currentPhoto >= 0 && currentPhoto < data.size()) {
679            toDelete = data.get(currentPhoto);
680
681            int result = new ExtendedDialog(
682                    Main.parent,
683                    tr("Delete image file from disk"),
684                    new String[] {tr("Cancel"), tr("Delete")})
685            .setButtonIcons(new String[] {"cancel", "dialogs/delete"})
686            .setContent(new JLabel(tr("<html><h3>Delete the file {0} from disk?<p>The image file will be permanently lost!</h3></html>",
687                    toDelete.getFile().getName()), ImageProvider.get("dialogs/geoimage/deletefromdisk"), SwingConstants.LEFT))
688                    .toggleEnable("geoimage.deleteimagefromdisk")
689                    .setCancelButton(1)
690                    .setDefaultButton(2)
691                    .showDialog()
692                    .getValue();
693
694            if (result == 2) {
695                data.remove(currentPhoto);
696                if (currentPhoto >= data.size()) {
697                    currentPhoto = data.size() - 1;
698                }
699                if (currentPhoto >= 0) {
700                    ImageViewerDialog.showImage(this, data.get(currentPhoto));
701                } else {
702                    ImageViewerDialog.showImage(this, null);
703                }
704
705                if (Utils.deleteFile(toDelete.getFile())) {
706                    Main.info("File "+toDelete.getFile()+" deleted. ");
707                } else {
708                    JOptionPane.showMessageDialog(
709                            Main.parent,
710                            tr("Image file could not be deleted."),
711                            tr("Error"),
712                            JOptionPane.ERROR_MESSAGE
713                            );
714                }
715
716                updateOffscreenBuffer = true;
717                Main.map.repaint();
718            }
719        }
720    }
721
722    public void copyCurrentPhotoPath() {
723        if (data != null && !data.isEmpty() && currentPhoto >= 0 && currentPhoto < data.size()) {
724            ClipboardUtils.copyString(data.get(currentPhoto).getFile().toString());
725        }
726    }
727
728    /**
729     * Removes a photo from the list of images by index.
730     * @param idx Image index
731     * @since 6392
732     */
733    public void removePhotoByIdx(int idx) {
734        if (idx >= 0 && data != null && idx < data.size()) {
735            data.remove(idx);
736        }
737    }
738
739    /**
740     * Returns the image that matches the position of the mouse event.
741     * @param evt Mouse event
742     * @return Image at mouse position, or {@code null} if there is no image at the mouse position
743     * @since 6392
744     */
745    public ImageEntry getPhotoUnderMouse(MouseEvent evt) {
746        if (data != null) {
747            for (int idx = data.size() - 1; idx >= 0; --idx) {
748                ImageEntry img = data.get(idx);
749                if (img.getPos() == null) {
750                    continue;
751                }
752                Point p = Main.map.mapView.getPoint(img.getPos());
753                Rectangle r;
754                if (useThumbs && img.hasThumbnail()) {
755                    Dimension d = scaledDimension(img.getThumbnail());
756                    r = new Rectangle(p.x - d.width / 2, p.y - d.height / 2, d.width, d.height);
757                } else {
758                    r = new Rectangle(p.x - icon.getIconWidth() / 2,
759                                      p.y - icon.getIconHeight() / 2,
760                                      icon.getIconWidth(),
761                                      icon.getIconHeight());
762                }
763                if (r.contains(evt.getPoint())) {
764                    return img;
765                }
766            }
767        }
768        return null;
769    }
770
771    /**
772     * Clears the currentPhoto, i.e. remove select marker, and optionally repaint.
773     * @param repaint Repaint flag
774     * @since 6392
775     */
776    public void clearCurrentPhoto(boolean repaint) {
777        currentPhoto = -1;
778        if (repaint) {
779            updateBufferAndRepaint();
780        }
781    }
782
783    /**
784     * Clears the currentPhoto of the other GeoImageLayer's. Otherwise there could be multiple selected photos.
785     */
786    private void clearOtherCurrentPhotos() {
787        for (GeoImageLayer layer:
788                 Main.getLayerManager().getLayersOfType(GeoImageLayer.class)) {
789            if (layer != this) {
790                layer.clearCurrentPhoto(false);
791            }
792        }
793    }
794
795    /**
796     * Registers a map mode for which the functionality of this layer should be available.
797     * @param mapMode Map mode to be registered
798     * @since 6392
799     */
800    public static void registerSupportedMapMode(MapMode mapMode) {
801        if (supportedMapModes == null) {
802            supportedMapModes = new ArrayList<>();
803        }
804        supportedMapModes.add(mapMode);
805    }
806
807    /**
808     * Determines if the functionality of this layer is available in
809     * the specified map mode. {@link SelectAction} and {@link LassoModeAction} are supported by default,
810     * other map modes can be registered.
811     * @param mapMode Map mode to be checked
812     * @return {@code true} if the map mode is supported,
813     *         {@code false} otherwise
814     */
815    private static boolean isSupportedMapMode(MapMode mapMode) {
816        if (mapMode instanceof SelectAction || mapMode instanceof LassoModeAction) {
817            return true;
818        }
819        if (supportedMapModes != null) {
820            for (MapMode supmmode: supportedMapModes) {
821                if (mapMode == supmmode) {
822                    return true;
823                }
824            }
825        }
826        return false;
827    }
828
829    @Override
830    public void hookUpMapView() {
831        mouseAdapter = new MouseAdapter() {
832            private boolean isMapModeOk() {
833                return Main.map.mapMode == null || isSupportedMapMode(Main.map.mapMode);
834            }
835
836            @Override
837            public void mousePressed(MouseEvent e) {
838                if (e.getButton() != MouseEvent.BUTTON1)
839                    return;
840                if (isVisible() && isMapModeOk()) {
841                    Main.map.mapView.repaint();
842                }
843            }
844
845            @Override
846            public void mouseReleased(MouseEvent ev) {
847                if (ev.getButton() != MouseEvent.BUTTON1)
848                    return;
849                if (data == null || !isVisible() || !isMapModeOk())
850                    return;
851
852                for (int i = data.size() - 1; i >= 0; --i) {
853                    ImageEntry e = data.get(i);
854                    if (e.getPos() == null) {
855                        continue;
856                    }
857                    Point p = Main.map.mapView.getPoint(e.getPos());
858                    Rectangle r;
859                    if (useThumbs && e.hasThumbnail()) {
860                        Dimension d = scaledDimension(e.getThumbnail());
861                        r = new Rectangle(p.x - d.width / 2, p.y - d.height / 2, d.width, d.height);
862                    } else {
863                        r = new Rectangle(p.x - icon.getIconWidth() / 2,
864                                p.y - icon.getIconHeight() / 2,
865                                icon.getIconWidth(),
866                                icon.getIconHeight());
867                    }
868                    if (r.contains(ev.getPoint())) {
869                        clearOtherCurrentPhotos();
870                        currentPhoto = i;
871                        ImageViewerDialog.showImage(GeoImageLayer.this, e);
872                        Main.map.repaint();
873                        break;
874                    }
875                }
876            }
877        };
878
879        mapModeListener = (oldMapMode, newMapMode) -> {
880            if (newMapMode == null || isSupportedMapMode(newMapMode)) {
881                Main.map.mapView.addMouseListener(mouseAdapter);
882            } else {
883                Main.map.mapView.removeMouseListener(mouseAdapter);
884            }
885        };
886
887        MapFrame.addMapModeChangeListener(mapModeListener);
888        mapModeListener.mapModeChange(null, Main.map.mapMode);
889
890        Main.getLayerManager().addActiveLayerChangeListener(e -> {
891            if (Main.getLayerManager().getActiveLayer() == this) {
892                // only in select mode it is possible to click the images
893                Main.map.selectSelectTool(false);
894            }
895        });
896
897        Main.getLayerManager().addLayerChangeListener(new LayerChangeListener() {
898            @Override
899            public void layerAdded(LayerAddEvent e) {
900                // Do nothing
901            }
902
903            @Override
904            public void layerRemoving(LayerRemoveEvent e) {
905                if (e.getRemovedLayer() == GeoImageLayer.this) {
906                    stopLoadThumbs();
907                    Main.map.mapView.removeMouseListener(mouseAdapter);
908                    MapFrame.removeMapModeChangeListener(mapModeListener);
909                    currentPhoto = -1;
910                    if (data != null) {
911                        data.clear();
912                    }
913                    data = null;
914                    // stop listening to layer change events
915                    Main.getLayerManager().removeLayerChangeListener(this);
916                }
917            }
918
919            @Override
920            public void layerOrderChanged(LayerOrderChangeEvent e) {
921                // Do nothing
922            }
923        });
924
925        Main.map.mapView.addPropertyChangeListener(this);
926        if (Main.map.getToggleDialog(ImageViewerDialog.class) == null) {
927            ImageViewerDialog.newInstance();
928            Main.map.addToggleDialog(ImageViewerDialog.getInstance());
929        }
930    }
931
932    @Override
933    public void propertyChange(PropertyChangeEvent evt) {
934        if (NavigatableComponent.PROPNAME_CENTER.equals(evt.getPropertyName()) ||
935                NavigatableComponent.PROPNAME_SCALE.equals(evt.getPropertyName())) {
936            updateOffscreenBuffer = true;
937        }
938    }
939
940    /**
941     * Start to load thumbnails.
942     */
943    public synchronized void startLoadThumbs() {
944        if (useThumbs && !thumbsLoaded && !thumbsLoaderRunning) {
945            stopLoadThumbs();
946            thumbsloader = new ThumbsLoader(this);
947            thumbsLoaderExecutor.submit(thumbsloader);
948            thumbsLoaderRunning = true;
949        }
950    }
951
952    /**
953     * Stop to load thumbnails.
954     *
955     * Can be called at any time to make sure that the
956     * thumbnail loader is stopped.
957     */
958    public synchronized void stopLoadThumbs() {
959        if (thumbsloader != null) {
960            thumbsloader.stop = true;
961        }
962        thumbsLoaderRunning = false;
963    }
964
965    /**
966     * Called to signal that the loading of thumbnails has finished.
967     *
968     * Usually called from {@link ThumbsLoader} in another thread.
969     */
970    public void thumbsLoaded() {
971        thumbsLoaded = true;
972    }
973
974    public void updateBufferAndRepaint() {
975        updateOffscreenBuffer = true;
976        invalidate();
977    }
978
979    /**
980     * Get list of images in layer.
981     * @return List of images in layer
982     */
983    public List<ImageEntry> getImages() {
984        return data == null ? Collections.<ImageEntry>emptyList() : new ArrayList<>(data);
985    }
986
987    /**
988     * Returns the associated GPX layer.
989     * @return The associated GPX layer
990     */
991    public GpxLayer getGpxLayer() {
992        return gpxLayer;
993    }
994
995    @Override
996    public void jumpToNextMarker() {
997        showNextPhoto();
998    }
999
1000    @Override
1001    public void jumpToPreviousMarker() {
1002        showPreviousPhoto();
1003    }
1004
1005    /**
1006     * Returns the current thumbnail display status.
1007     * {@code true}: thumbnails are displayed, {@code false}: an icon is displayed instead of thumbnails.
1008     * @return Current thumbnail display status
1009     * @since 6392
1010     */
1011    public boolean isUseThumbs() {
1012        return useThumbs;
1013    }
1014
1015    /**
1016     * Enables or disables the display of thumbnails.  Does not update the display.
1017     * @param useThumbs New thumbnail display status
1018     * @since 6392
1019     */
1020    public void setUseThumbs(boolean useThumbs) {
1021        this.useThumbs = useThumbs;
1022        if (useThumbs && !thumbsLoaded) {
1023            startLoadThumbs();
1024        } else if (!useThumbs) {
1025            stopLoadThumbs();
1026        }
1027    }
1028}