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            // read the image files
195            List<ImageEntry> entries = new ArrayList<>(files.size());
196
197            for (File f : files) {
198
199                if (canceled) {
200                    break;
201                }
202
203                progressMonitor.subTask(tr("Reading {0}...", f.getName()));
204                progressMonitor.worked(1);
205
206                ImageEntry e = new ImageEntry(f);
207                e.extractExif();
208                entries.add(e);
209            }
210            layer = new GeoImageLayer(entries, gpxLayer);
211            files.clear();
212        }
213
214        private void addRecursiveFiles(Collection<File> files, Collection<File> sel) {
215            boolean nullFile = false;
216
217            for (File f : sel) {
218
219                if (canceled) {
220                    break;
221                }
222
223                if (f == null) {
224                    nullFile = true;
225
226                } else if (f.isDirectory()) {
227                    String canonical = null;
228                    try {
229                        canonical = f.getCanonicalPath();
230                    } catch (IOException e) {
231                        Main.error(e);
232                        rememberError(tr("Unable to get canonical path for directory {0}\n",
233                                f.getAbsolutePath()));
234                    }
235
236                    if (canonical == null || loadedDirectories.contains(canonical)) {
237                        continue;
238                    } else {
239                        loadedDirectories.add(canonical);
240                    }
241
242                    File[] children = f.listFiles(JpgImporter.FILE_FILTER_WITH_FOLDERS);
243                    if (children != null) {
244                        progressMonitor.subTask(tr("Scanning directory {0}", f.getPath()));
245                        addRecursiveFiles(files, Arrays.asList(children));
246                    } else {
247                        rememberError(tr("Error while getting files from directory {0}\n", f.getPath()));
248                    }
249
250                } else {
251                    files.add(f);
252                }
253            }
254
255            if (nullFile) {
256                throw new IllegalStateException(tr("One of the selected files was null"));
257            }
258        }
259
260        private String formatErrorMessages() {
261            StringBuilder sb = new StringBuilder();
262            sb.append("<html>");
263            if (errorMessages.size() == 1) {
264                sb.append(errorMessages.iterator().next());
265            } else {
266                sb.append(Utils.joinAsHtmlUnorderedList(errorMessages));
267            }
268            sb.append("</html>");
269            return sb.toString();
270        }
271
272        @Override protected void finish() {
273            if (!errorMessages.isEmpty()) {
274                JOptionPane.showMessageDialog(
275                        Main.parent,
276                        formatErrorMessages(),
277                        tr("Error"),
278                        JOptionPane.ERROR_MESSAGE
279                        );
280            }
281            if (layer != null) {
282                Main.getLayerManager().addLayer(layer);
283
284                if (!canceled && layer.data != null && !layer.data.isEmpty()) {
285                    boolean noGeotagFound = true;
286                    for (ImageEntry e : layer.data) {
287                        if (e.getPos() != null) {
288                            noGeotagFound = false;
289                        }
290                    }
291                    if (noGeotagFound) {
292                        new CorrelateGpxWithImages(layer).actionPerformed(null);
293                    }
294                }
295            }
296        }
297
298        @Override protected void cancel() {
299            canceled = true;
300        }
301    }
302
303    public static void create(Collection<File> files, GpxLayer gpxLayer) {
304        Main.worker.execute(new Loader(files, gpxLayer));
305    }
306
307    @Override
308    public Icon getIcon() {
309        return ImageProvider.get("dialogs/geoimage");
310    }
311
312    public static void registerMenuAddition(Action addition) {
313        menuAdditions.add(addition);
314    }
315
316    @Override
317    public Action[] getMenuEntries() {
318
319        List<Action> entries = new ArrayList<>();
320        entries.add(LayerListDialog.getInstance().createShowHideLayerAction());
321        entries.add(LayerListDialog.getInstance().createDeleteLayerAction());
322        entries.add(LayerListDialog.getInstance().createMergeLayerAction(this));
323        entries.add(new RenameLayerAction(null, this));
324        entries.add(SeparatorLayerAction.INSTANCE);
325        entries.add(new CorrelateGpxWithImages(this));
326        entries.add(new ShowThumbnailAction(this));
327        if (!menuAdditions.isEmpty()) {
328            entries.add(SeparatorLayerAction.INSTANCE);
329            entries.addAll(menuAdditions);
330        }
331        entries.add(SeparatorLayerAction.INSTANCE);
332        entries.add(new JumpToNextMarker(this));
333        entries.add(new JumpToPreviousMarker(this));
334        entries.add(SeparatorLayerAction.INSTANCE);
335        entries.add(new LayerListPopup.InfoAction(this));
336
337        return entries.toArray(new Action[entries.size()]);
338
339    }
340
341    /**
342     * Prepare the string that is displayed if layer information is requested.
343     * @return String with layer information
344     */
345    private String infoText() {
346        int tagged = 0;
347        int newdata = 0;
348        int n = 0;
349        if (data != null) {
350            n = data.size();
351            for (ImageEntry e : data) {
352                if (e.getPos() != null) {
353                    tagged++;
354                }
355                if (e.hasNewGpsData()) {
356                    newdata++;
357                }
358            }
359        }
360        return "<html>"
361                + trn("{0} image loaded.", "{0} images loaded.", n, n)
362                + ' ' + trn("{0} was found to be GPS tagged.", "{0} were found to be GPS tagged.", tagged, tagged)
363                + (newdata > 0 ? "<br>" + trn("{0} has updated GPS data.", "{0} have updated GPS data.", newdata, newdata) : "")
364                + "</html>";
365    }
366
367    @Override public Object getInfoComponent() {
368        return infoText();
369    }
370
371    @Override
372    public String getToolTipText() {
373        return infoText();
374    }
375
376    /**
377     * Determines if data managed by this layer has been modified.  That is
378     * the case if one image has modified GPS data.
379     * @return {@code true} if data has been modified; {@code false}, otherwise
380     */
381    @Override
382    public boolean isModified() {
383        if (data != null) {
384            for (ImageEntry e : data) {
385                if (e.hasNewGpsData()) {
386                    return true;
387                }
388            }
389        }
390        return false;
391    }
392
393    @Override
394    public boolean isMergable(Layer other) {
395        return other instanceof GeoImageLayer;
396    }
397
398    @Override
399    public void mergeFrom(Layer from) {
400        if (!(from instanceof GeoImageLayer))
401            throw new IllegalArgumentException("not a GeoImageLayer: " + from);
402        GeoImageLayer l = (GeoImageLayer) from;
403
404        // Stop to load thumbnails on both layers.  Thumbnail loading will continue the next time
405        // the layer is painted.
406        stopLoadThumbs();
407        l.stopLoadThumbs();
408
409        final ImageEntry selected = l.data != null && l.currentPhoto >= 0 ? l.data.get(l.currentPhoto) : null;
410
411        if (l.data != null) {
412            data.addAll(l.data);
413        }
414        Collections.sort(data);
415
416        // Supress the double photos.
417        if (data.size() > 1) {
418            ImageEntry cur;
419            ImageEntry prev = data.get(data.size() - 1);
420            for (int i = data.size() - 2; i >= 0; i--) {
421                cur = data.get(i);
422                if (cur.getFile().equals(prev.getFile())) {
423                    data.remove(i);
424                } else {
425                    prev = cur;
426                }
427            }
428        }
429
430        if (selected != null && !data.isEmpty()) {
431            GuiHelper.runInEDTAndWait(() -> {
432                for (int i = 0; i < data.size(); i++) {
433                    if (selected.equals(data.get(i))) {
434                        currentPhoto = i;
435                        ImageViewerDialog.showImage(this, data.get(i));
436                        break;
437                    }
438                }
439            });
440        }
441
442        setName(l.getName());
443        thumbsLoaded &= l.thumbsLoaded;
444    }
445
446    private static Dimension scaledDimension(Image thumb) {
447        final double d = Main.map.mapView.getDist100Pixel();
448        final double size = 10 /*meter*/;     /* size of the photo on the map */
449        double s = size * 100 /*px*/ / d;
450
451        final double sMin = ThumbsLoader.minSize;
452        final double sMax = ThumbsLoader.maxSize;
453
454        if (s < sMin) {
455            s = sMin;
456        }
457        if (s > sMax) {
458            s = sMax;
459        }
460        final double f = s / sMax;  /* scale factor */
461
462        if (thumb == null)
463            return null;
464
465        return new Dimension(
466                (int) Math.round(f * thumb.getWidth(null)),
467                (int) Math.round(f * thumb.getHeight(null)));
468    }
469
470    @Override
471    public void paint(Graphics2D g, MapView mv, Bounds bounds) {
472        int width = mv.getWidth();
473        int height = mv.getHeight();
474        Rectangle clip = g.getClipBounds();
475        if (useThumbs) {
476            if (!thumbsLoaded) {
477                startLoadThumbs();
478            }
479
480            if (null == offscreenBuffer || offscreenBuffer.getWidth() != width  // reuse the old buffer if possible
481                    || offscreenBuffer.getHeight() != height) {
482                offscreenBuffer = new BufferedImage(width, height,
483                        BufferedImage.TYPE_INT_ARGB);
484                updateOffscreenBuffer = true;
485            }
486
487            if (updateOffscreenBuffer) {
488                Graphics2D tempG = offscreenBuffer.createGraphics();
489                tempG.setColor(new Color(0, 0, 0, 0));
490                Composite saveComp = tempG.getComposite();
491                tempG.setComposite(AlphaComposite.Clear);   // remove the old images
492                tempG.fillRect(0, 0, width, height);
493                tempG.setComposite(saveComp);
494
495                if (data != null) {
496                    for (ImageEntry e : data) {
497                        if (e.getPos() == null) {
498                            continue;
499                        }
500                        Point p = mv.getPoint(e.getPos());
501                        if (e.hasThumbnail()) {
502                            Dimension d = scaledDimension(e.getThumbnail());
503                            if (d != null) {
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                            }
509                        } else { // thumbnail not loaded yet
510                            icon.paintIcon(mv, tempG,
511                                    p.x - icon.getIconWidth() / 2,
512                                    p.y - icon.getIconHeight() / 2);
513                        }
514                    }
515                }
516                updateOffscreenBuffer = false;
517            }
518            g.drawImage(offscreenBuffer, 0, 0, null);
519        } else if (data != null) {
520            for (ImageEntry e : data) {
521                if (e.getPos() == null) {
522                    continue;
523                }
524                Point p = mv.getPoint(e.getPos());
525                icon.paintIcon(mv, g,
526                        p.x - icon.getIconWidth() / 2,
527                        p.y - icon.getIconHeight() / 2);
528            }
529        }
530
531        if (currentPhoto >= 0 && currentPhoto < data.size()) {
532            ImageEntry e = data.get(currentPhoto);
533
534            if (e.getPos() != null) {
535                Point p = mv.getPoint(e.getPos());
536
537                int imgWidth;
538                int imgHeight;
539                if (useThumbs && e.hasThumbnail()) {
540                    Dimension d = scaledDimension(e.getThumbnail());
541                    if (d != null) {
542                        imgWidth = d.width;
543                        imgHeight = d.height;
544                    } else {
545                        imgWidth = -1;
546                        imgHeight = -1;
547                    }
548                } else {
549                    imgWidth = selectedIcon.getIconWidth();
550                    imgHeight = selectedIcon.getIconHeight();
551                }
552
553                if (e.getExifImgDir() != null) {
554                    // Multiplier must be larger than sqrt(2)/2=0.71.
555                    double arrowlength = Math.max(25, Math.max(imgWidth, imgHeight) * 0.85);
556                    double arrowwidth = arrowlength / 1.4;
557
558                    double dir = e.getExifImgDir();
559                    // Rotate 90 degrees CCW
560                    double headdir = (dir < 90) ? dir + 270 : dir - 90;
561                    double leftdir = (headdir < 90) ? headdir + 270 : headdir - 90;
562                    double rightdir = (headdir > 270) ? headdir - 270 : headdir + 90;
563
564                    double ptx = p.x + Math.cos(Math.toRadians(headdir)) * arrowlength;
565                    double pty = p.y + Math.sin(Math.toRadians(headdir)) * arrowlength;
566
567                    double ltx = p.x + Math.cos(Math.toRadians(leftdir)) * arrowwidth/2;
568                    double lty = p.y + Math.sin(Math.toRadians(leftdir)) * arrowwidth/2;
569
570                    double rtx = p.x + Math.cos(Math.toRadians(rightdir)) * arrowwidth/2;
571                    double rty = p.y + Math.sin(Math.toRadians(rightdir)) * arrowwidth/2;
572
573                    g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
574                    g.setColor(new Color(255, 255, 255, 192));
575                    int[] xar = {(int) ltx, (int) ptx, (int) rtx, (int) ltx};
576                    int[] yar = {(int) lty, (int) pty, (int) rty, (int) lty};
577                    g.fillPolygon(xar, yar, 4);
578                    g.setColor(Color.black);
579                    g.setStroke(new BasicStroke(1.2f));
580                    g.drawPolyline(xar, yar, 3);
581                }
582
583                if (useThumbs && e.hasThumbnail()) {
584                    g.setColor(new Color(128, 0, 0, 122));
585                    g.fillRect(p.x - imgWidth / 2, p.y - imgHeight / 2, imgWidth, imgHeight);
586                } else {
587                    selectedIcon.paintIcon(mv, g,
588                            p.x - imgWidth / 2,
589                            p.y - imgHeight / 2);
590
591                }
592            }
593        }
594    }
595
596    @Override
597    public void visitBoundingBox(BoundingXYVisitor v) {
598        for (ImageEntry e : data) {
599            v.visit(e.getPos());
600        }
601    }
602
603    /**
604     * Shows next photo.
605     */
606    public void showNextPhoto() {
607        if (data != null && !data.isEmpty()) {
608            currentPhoto++;
609            if (currentPhoto >= data.size()) {
610                currentPhoto = data.size() - 1;
611            }
612            ImageViewerDialog.showImage(this, data.get(currentPhoto));
613        } else {
614            currentPhoto = -1;
615        }
616        Main.map.repaint();
617    }
618
619    /**
620     * Shows previous photo.
621     */
622    public void showPreviousPhoto() {
623        if (data != null && !data.isEmpty()) {
624            currentPhoto--;
625            if (currentPhoto < 0) {
626                currentPhoto = 0;
627            }
628            ImageViewerDialog.showImage(this, data.get(currentPhoto));
629        } else {
630            currentPhoto = -1;
631        }
632        Main.map.repaint();
633    }
634
635    /**
636     * Shows first photo.
637     */
638    public void showFirstPhoto() {
639        if (data != null && !data.isEmpty()) {
640            currentPhoto = 0;
641            ImageViewerDialog.showImage(this, data.get(currentPhoto));
642        } else {
643            currentPhoto = -1;
644        }
645        Main.map.repaint();
646    }
647
648    /**
649     * Shows last photo.
650     */
651    public void showLastPhoto() {
652        if (data != null && !data.isEmpty()) {
653            currentPhoto = data.size() - 1;
654            ImageViewerDialog.showImage(this, data.get(currentPhoto));
655        } else {
656            currentPhoto = -1;
657        }
658        Main.map.repaint();
659    }
660
661    public void checkPreviousNextButtons() {
662        ImageViewerDialog.setNextEnabled(data != null && currentPhoto < data.size() - 1);
663        ImageViewerDialog.setPreviousEnabled(currentPhoto > 0);
664    }
665
666    public void removeCurrentPhoto() {
667        if (data != null && !data.isEmpty() && currentPhoto >= 0 && currentPhoto < data.size()) {
668            data.remove(currentPhoto);
669            if (currentPhoto >= data.size()) {
670                currentPhoto = data.size() - 1;
671            }
672            if (currentPhoto >= 0) {
673                ImageViewerDialog.showImage(this, data.get(currentPhoto));
674            } else {
675                ImageViewerDialog.showImage(this, null);
676            }
677            updateOffscreenBuffer = true;
678            Main.map.repaint();
679        }
680    }
681
682    public void removeCurrentPhotoFromDisk() {
683        ImageEntry toDelete;
684        if (data != null && !data.isEmpty() && currentPhoto >= 0 && currentPhoto < data.size()) {
685            toDelete = data.get(currentPhoto);
686
687            int result = new ExtendedDialog(
688                    Main.parent,
689                    tr("Delete image file from disk"),
690                    new String[] {tr("Cancel"), tr("Delete")})
691            .setButtonIcons(new String[] {"cancel", "dialogs/delete"})
692            .setContent(new JLabel(tr("<html><h3>Delete the file {0} from disk?<p>The image file will be permanently lost!</h3></html>",
693                    toDelete.getFile().getName()), ImageProvider.get("dialogs/geoimage/deletefromdisk"), SwingConstants.LEFT))
694                    .toggleEnable("geoimage.deleteimagefromdisk")
695                    .setCancelButton(1)
696                    .setDefaultButton(2)
697                    .showDialog()
698                    .getValue();
699
700            if (result == 2) {
701                data.remove(currentPhoto);
702                if (currentPhoto >= data.size()) {
703                    currentPhoto = data.size() - 1;
704                }
705                if (currentPhoto >= 0) {
706                    ImageViewerDialog.showImage(this, data.get(currentPhoto));
707                } else {
708                    ImageViewerDialog.showImage(this, null);
709                }
710
711                if (Utils.deleteFile(toDelete.getFile())) {
712                    Main.info("File "+toDelete.getFile()+" deleted. ");
713                } else {
714                    JOptionPane.showMessageDialog(
715                            Main.parent,
716                            tr("Image file could not be deleted."),
717                            tr("Error"),
718                            JOptionPane.ERROR_MESSAGE
719                            );
720                }
721
722                updateOffscreenBuffer = true;
723                Main.map.repaint();
724            }
725        }
726    }
727
728    public void copyCurrentPhotoPath() {
729        if (data != null && !data.isEmpty() && currentPhoto >= 0 && currentPhoto < data.size()) {
730            ClipboardUtils.copyString(data.get(currentPhoto).getFile().toString());
731        }
732    }
733
734    /**
735     * Removes a photo from the list of images by index.
736     * @param idx Image index
737     * @since 6392
738     */
739    public void removePhotoByIdx(int idx) {
740        if (idx >= 0 && data != null && idx < data.size()) {
741            data.remove(idx);
742        }
743    }
744
745    /**
746     * Returns the image that matches the position of the mouse event.
747     * @param evt Mouse event
748     * @return Image at mouse position, or {@code null} if there is no image at the mouse position
749     * @since 6392
750     */
751    public ImageEntry getPhotoUnderMouse(MouseEvent evt) {
752        if (data != null) {
753            for (int idx = data.size() - 1; idx >= 0; --idx) {
754                ImageEntry img = data.get(idx);
755                if (img.getPos() == null) {
756                    continue;
757                }
758                Point p = Main.map.mapView.getPoint(img.getPos());
759                Rectangle r;
760                if (useThumbs && img.hasThumbnail()) {
761                    Dimension d = scaledDimension(img.getThumbnail());
762                    if (d != null)
763                        r = new Rectangle(p.x - d.width / 2, p.y - d.height / 2, d.width, d.height);
764                    else
765                        r = null;
766                } else {
767                    r = new Rectangle(p.x - icon.getIconWidth() / 2,
768                                      p.y - icon.getIconHeight() / 2,
769                                      icon.getIconWidth(),
770                                      icon.getIconHeight());
771                }
772                if (r != null && r.contains(evt.getPoint())) {
773                    return img;
774                }
775            }
776        }
777        return null;
778    }
779
780    /**
781     * Clears the currentPhoto, i.e. remove select marker, and optionally repaint.
782     * @param repaint Repaint flag
783     * @since 6392
784     */
785    public void clearCurrentPhoto(boolean repaint) {
786        currentPhoto = -1;
787        if (repaint) {
788            updateBufferAndRepaint();
789        }
790    }
791
792    /**
793     * Clears the currentPhoto of the other GeoImageLayer's. Otherwise there could be multiple selected photos.
794     */
795    private void clearOtherCurrentPhotos() {
796        for (GeoImageLayer layer:
797                 Main.getLayerManager().getLayersOfType(GeoImageLayer.class)) {
798            if (layer != this) {
799                layer.clearCurrentPhoto(false);
800            }
801        }
802    }
803
804    /**
805     * Registers a map mode for which the functionality of this layer should be available.
806     * @param mapMode Map mode to be registered
807     * @since 6392
808     */
809    public static void registerSupportedMapMode(MapMode mapMode) {
810        if (supportedMapModes == null) {
811            supportedMapModes = new ArrayList<>();
812        }
813        supportedMapModes.add(mapMode);
814    }
815
816    /**
817     * Determines if the functionality of this layer is available in
818     * the specified map mode. {@link SelectAction} and {@link LassoModeAction} are supported by default,
819     * other map modes can be registered.
820     * @param mapMode Map mode to be checked
821     * @return {@code true} if the map mode is supported,
822     *         {@code false} otherwise
823     */
824    private static boolean isSupportedMapMode(MapMode mapMode) {
825        if (mapMode instanceof SelectAction || mapMode instanceof LassoModeAction) {
826            return true;
827        }
828        if (supportedMapModes != null) {
829            for (MapMode supmmode: supportedMapModes) {
830                if (mapMode == supmmode) {
831                    return true;
832                }
833            }
834        }
835        return false;
836    }
837
838    @Override
839    public void hookUpMapView() {
840        mouseAdapter = new MouseAdapter() {
841            private boolean isMapModeOk() {
842                return Main.map.mapMode == null || isSupportedMapMode(Main.map.mapMode);
843            }
844
845            @Override
846            public void mousePressed(MouseEvent e) {
847                if (e.getButton() != MouseEvent.BUTTON1)
848                    return;
849                if (isVisible() && isMapModeOk()) {
850                    Main.map.mapView.repaint();
851                }
852            }
853
854            @Override
855            public void mouseReleased(MouseEvent ev) {
856                if (ev.getButton() != MouseEvent.BUTTON1)
857                    return;
858                if (data == null || !isVisible() || !isMapModeOk())
859                    return;
860
861                for (int i = data.size() - 1; i >= 0; --i) {
862                    ImageEntry e = data.get(i);
863                    if (e.getPos() == null) {
864                        continue;
865                    }
866                    Point p = Main.map.mapView.getPoint(e.getPos());
867                    Rectangle r;
868                    if (useThumbs && e.hasThumbnail()) {
869                        Dimension d = scaledDimension(e.getThumbnail());
870                        if (d != null)
871                            r = new Rectangle(p.x - d.width / 2, p.y - d.height / 2, d.width, d.height);
872                        else
873                            r = null;
874                    } else {
875                        r = new Rectangle(p.x - icon.getIconWidth() / 2,
876                                p.y - icon.getIconHeight() / 2,
877                                icon.getIconWidth(),
878                                icon.getIconHeight());
879                    }
880                    if (r != null && r.contains(ev.getPoint())) {
881                        clearOtherCurrentPhotos();
882                        currentPhoto = i;
883                        ImageViewerDialog.showImage(GeoImageLayer.this, e);
884                        Main.map.repaint();
885                        break;
886                    }
887                }
888            }
889        };
890
891        mapModeListener = (oldMapMode, newMapMode) -> {
892            if (newMapMode == null || isSupportedMapMode(newMapMode)) {
893                Main.map.mapView.addMouseListener(mouseAdapter);
894            } else {
895                Main.map.mapView.removeMouseListener(mouseAdapter);
896            }
897        };
898
899        MapFrame.addMapModeChangeListener(mapModeListener);
900        mapModeListener.mapModeChange(null, Main.map.mapMode);
901
902        Main.getLayerManager().addActiveLayerChangeListener(e -> {
903            if (Main.getLayerManager().getActiveLayer() == this) {
904                // only in select mode it is possible to click the images
905                Main.map.selectSelectTool(false);
906            }
907        });
908
909        Main.getLayerManager().addLayerChangeListener(new LayerChangeListener() {
910            @Override
911            public void layerAdded(LayerAddEvent e) {
912                // Do nothing
913            }
914
915            @Override
916            public void layerRemoving(LayerRemoveEvent e) {
917                if (e.getRemovedLayer() == GeoImageLayer.this) {
918                    stopLoadThumbs();
919                    Main.map.mapView.removeMouseListener(mouseAdapter);
920                    MapFrame.removeMapModeChangeListener(mapModeListener);
921                    currentPhoto = -1;
922                    if (data != null) {
923                        data.clear();
924                    }
925                    data = null;
926                    // stop listening to layer change events
927                    Main.getLayerManager().removeLayerChangeListener(this);
928                }
929            }
930
931            @Override
932            public void layerOrderChanged(LayerOrderChangeEvent e) {
933                // Do nothing
934            }
935        });
936
937        Main.map.mapView.addPropertyChangeListener(this);
938        if (Main.map.getToggleDialog(ImageViewerDialog.class) == null) {
939            ImageViewerDialog.newInstance();
940            Main.map.addToggleDialog(ImageViewerDialog.getInstance());
941        }
942    }
943
944    @Override
945    public void propertyChange(PropertyChangeEvent evt) {
946        if (NavigatableComponent.PROPNAME_CENTER.equals(evt.getPropertyName()) ||
947                NavigatableComponent.PROPNAME_SCALE.equals(evt.getPropertyName())) {
948            updateOffscreenBuffer = true;
949        }
950    }
951
952    /**
953     * Start to load thumbnails.
954     */
955    public synchronized void startLoadThumbs() {
956        if (useThumbs && !thumbsLoaded && !thumbsLoaderRunning) {
957            stopLoadThumbs();
958            thumbsloader = new ThumbsLoader(this);
959            thumbsLoaderExecutor.submit(thumbsloader);
960            thumbsLoaderRunning = true;
961        }
962    }
963
964    /**
965     * Stop to load thumbnails.
966     *
967     * Can be called at any time to make sure that the
968     * thumbnail loader is stopped.
969     */
970    public synchronized void stopLoadThumbs() {
971        if (thumbsloader != null) {
972            thumbsloader.stop = true;
973        }
974        thumbsLoaderRunning = false;
975    }
976
977    /**
978     * Called to signal that the loading of thumbnails has finished.
979     *
980     * Usually called from {@link ThumbsLoader} in another thread.
981     */
982    public void thumbsLoaded() {
983        thumbsLoaded = true;
984    }
985
986    public void updateBufferAndRepaint() {
987        updateOffscreenBuffer = true;
988        invalidate();
989    }
990
991    /**
992     * Get list of images in layer.
993     * @return List of images in layer
994     */
995    public List<ImageEntry> getImages() {
996        return data == null ? Collections.<ImageEntry>emptyList() : new ArrayList<>(data);
997    }
998
999    /**
1000     * Returns the associated GPX layer.
1001     * @return The associated GPX layer
1002     */
1003    public GpxLayer getGpxLayer() {
1004        return gpxLayer;
1005    }
1006
1007    @Override
1008    public void jumpToNextMarker() {
1009        showNextPhoto();
1010    }
1011
1012    @Override
1013    public void jumpToPreviousMarker() {
1014        showPreviousPhoto();
1015    }
1016
1017    /**
1018     * Returns the current thumbnail display status.
1019     * {@code true}: thumbnails are displayed, {@code false}: an icon is displayed instead of thumbnails.
1020     * @return Current thumbnail display status
1021     * @since 6392
1022     */
1023    public boolean isUseThumbs() {
1024        return useThumbs;
1025    }
1026
1027    /**
1028     * Enables or disables the display of thumbnails.  Does not update the display.
1029     * @param useThumbs New thumbnail display status
1030     * @since 6392
1031     */
1032    public void setUseThumbs(boolean useThumbs) {
1033        this.useThumbs = useThumbs;
1034        if (useThumbs && !thumbsLoaded) {
1035            startLoadThumbs();
1036        } else if (!useThumbs) {
1037            stopLoadThumbs();
1038        }
1039    }
1040}