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.dialogs.LayerListDialog;
056import org.openstreetmap.josm.gui.dialogs.LayerListPopup;
057import org.openstreetmap.josm.gui.layer.AbstractModifiableLayer;
058import org.openstreetmap.josm.gui.layer.GpxLayer;
059import org.openstreetmap.josm.gui.layer.JumpToMarkerActions.JumpToMarkerLayer;
060import org.openstreetmap.josm.gui.layer.JumpToMarkerActions.JumpToNextMarker;
061import org.openstreetmap.josm.gui.layer.JumpToMarkerActions.JumpToPreviousMarker;
062import org.openstreetmap.josm.gui.layer.Layer;
063import org.openstreetmap.josm.gui.layer.LayerManager.LayerAddEvent;
064import org.openstreetmap.josm.gui.layer.LayerManager.LayerChangeListener;
065import org.openstreetmap.josm.gui.layer.LayerManager.LayerOrderChangeEvent;
066import org.openstreetmap.josm.gui.layer.LayerManager.LayerRemoveEvent;
067import org.openstreetmap.josm.gui.layer.MainLayerManager.ActiveLayerChangeEvent;
068import org.openstreetmap.josm.gui.layer.MainLayerManager.ActiveLayerChangeListener;
069import org.openstreetmap.josm.gui.util.GuiHelper;
070import org.openstreetmap.josm.io.JpgImporter;
071import org.openstreetmap.josm.tools.ImageProvider;
072import org.openstreetmap.josm.tools.Utils;
073
074/**
075 * Layer displaying geottaged pictures.
076 */
077public class GeoImageLayer extends AbstractModifiableLayer implements PropertyChangeListener, JumpToMarkerLayer {
078
079    private static List<Action> menuAdditions = new LinkedList<>();
080
081    private static volatile List<MapMode> supportedMapModes;
082
083    List<ImageEntry> data;
084    GpxLayer gpxLayer;
085
086    private final Icon icon = ImageProvider.get("dialogs/geoimage/photo-marker");
087    private final Icon selectedIcon = ImageProvider.get("dialogs/geoimage/photo-marker-selected");
088
089    private int currentPhoto = -1;
090
091    boolean useThumbs;
092    private final ExecutorService thumbsLoaderExecutor =
093            Executors.newSingleThreadExecutor(Utils.newThreadFactory("thumbnail-loader-%d", Thread.MIN_PRIORITY));
094    private ThumbsLoader thumbsloader;
095    private boolean thumbsLoaderRunning;
096    volatile boolean thumbsLoaded;
097    private BufferedImage offscreenBuffer;
098    boolean updateOffscreenBuffer = true;
099
100    private MouseAdapter mouseAdapter;
101    private MapModeChangeListener mapModeListener;
102
103    /**
104     * Constructs a new {@code GeoImageLayer}.
105     * @param data The list of images to display
106     * @param gpxLayer The associated GPX layer
107     */
108    public GeoImageLayer(final List<ImageEntry> data, GpxLayer gpxLayer) {
109        this(data, gpxLayer, null, false);
110    }
111
112    /**
113     * Constructs a new {@code GeoImageLayer}.
114     * @param data The list of images to display
115     * @param gpxLayer The associated GPX layer
116     * @param name Layer name
117     * @since 6392
118     */
119    public GeoImageLayer(final List<ImageEntry> data, GpxLayer gpxLayer, final String name) {
120        this(data, gpxLayer, name, 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 useThumbs Thumbnail display flag
128     * @since 6392
129     */
130    public GeoImageLayer(final List<ImageEntry> data, GpxLayer gpxLayer, boolean useThumbs) {
131        this(data, gpxLayer, null, useThumbs);
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 name Layer name
139     * @param useThumbs Thumbnail display flag
140     * @since 6392
141     */
142    public GeoImageLayer(final List<ImageEntry> data, GpxLayer gpxLayer, final String name, boolean useThumbs) {
143        super(name != null ? name : tr("Geotagged Images"));
144        if (data != null) {
145            Collections.sort(data);
146        }
147        this.data = data;
148        this.gpxLayer = gpxLayer;
149        this.useThumbs = useThumbs;
150    }
151
152    /**
153     * Loads a set of images, while displaying a dialog that indicates what the plugin is currently doing.
154     * In facts, this object is instantiated with a list of files. These files may be JPEG files or
155     * directories. In case of directories, they are scanned to find all the images they contain.
156     * Then all the images that have be found are loaded as ImageEntry instances.
157     */
158    static final class Loader extends PleaseWaitRunnable {
159
160        private boolean canceled;
161        private GeoImageLayer layer;
162        private final Collection<File> selection;
163        private final Set<String> loadedDirectories = new HashSet<>();
164        private final Set<String> errorMessages;
165        private final GpxLayer gpxLayer;
166
167        Loader(Collection<File> selection, GpxLayer gpxLayer) {
168            super(tr("Extracting GPS locations from EXIF"));
169            this.selection = selection;
170            this.gpxLayer = gpxLayer;
171            errorMessages = new LinkedHashSet<>();
172        }
173
174        protected void rememberError(String message) {
175            this.errorMessages.add(message);
176        }
177
178        @Override
179        protected void realRun() throws IOException {
180
181            progressMonitor.subTask(tr("Starting directory scan"));
182            Collection<File> files = new ArrayList<>();
183            try {
184                addRecursiveFiles(files, selection);
185            } catch (IllegalStateException 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        protected 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(new Runnable() {
433                @Override
434                public void run() {
435                    for (int i = 0; i < data.size(); i++) {
436                        if (selected.equals(data.get(i))) {
437                            currentPhoto = i;
438                            ImageViewerDialog.showImage(GeoImageLayer.this, data.get(i));
439                            break;
440                        }
441                    }
442                }
443            });
444        }
445
446        setName(l.getName());
447        thumbsLoaded &= l.thumbsLoaded;
448    }
449
450    private static Dimension scaledDimension(Image thumb) {
451        final double d = Main.map.mapView.getDist100Pixel();
452        final double size = 10 /*meter*/;     /* size of the photo on the map */
453        double s = size * 100 /*px*/ / d;
454
455        final double sMin = ThumbsLoader.minSize;
456        final double sMax = ThumbsLoader.maxSize;
457
458        if (s < sMin) {
459            s = sMin;
460        }
461        if (s > sMax) {
462            s = sMax;
463        }
464        final double f = s / sMax;  /* scale factor */
465
466        if (thumb == null)
467            return null;
468
469        return new Dimension(
470                (int) Math.round(f * thumb.getWidth(null)),
471                (int) Math.round(f * thumb.getHeight(null)));
472    }
473
474    @Override
475    public void paint(Graphics2D g, MapView mv, Bounds bounds) {
476        int width = mv.getWidth();
477        int height = mv.getHeight();
478        Rectangle clip = g.getClipBounds();
479        if (useThumbs) {
480            if (!thumbsLoaded) {
481                startLoadThumbs();
482            }
483
484            if (null == offscreenBuffer || offscreenBuffer.getWidth() != width  // reuse the old buffer if possible
485                    || offscreenBuffer.getHeight() != height) {
486                offscreenBuffer = new BufferedImage(width, height,
487                        BufferedImage.TYPE_INT_ARGB);
488                updateOffscreenBuffer = true;
489            }
490
491            if (updateOffscreenBuffer) {
492                Graphics2D tempG = offscreenBuffer.createGraphics();
493                tempG.setColor(new Color(0, 0, 0, 0));
494                Composite saveComp = tempG.getComposite();
495                tempG.setComposite(AlphaComposite.Clear);   // remove the old images
496                tempG.fillRect(0, 0, width, height);
497                tempG.setComposite(saveComp);
498
499                if (data != null) {
500                    for (ImageEntry e : data) {
501                        if (e.getPos() == null) {
502                            continue;
503                        }
504                        Point p = mv.getPoint(e.getPos());
505                        if (e.hasThumbnail()) {
506                            Dimension d = scaledDimension(e.getThumbnail());
507                            Rectangle target = new Rectangle(p.x - d.width / 2, p.y - d.height / 2, d.width, d.height);
508                            if (clip.intersects(target)) {
509                                tempG.drawImage(e.getThumbnail(), target.x, target.y, target.width, target.height, null);
510                            }
511                        } else { // thumbnail not loaded yet
512                            icon.paintIcon(mv, tempG,
513                                    p.x - icon.getIconWidth() / 2,
514                                    p.y - icon.getIconHeight() / 2);
515                        }
516                    }
517                }
518                updateOffscreenBuffer = false;
519            }
520            g.drawImage(offscreenBuffer, 0, 0, null);
521        } else if (data != null) {
522            for (ImageEntry e : data) {
523                if (e.getPos() == null) {
524                    continue;
525                }
526                Point p = mv.getPoint(e.getPos());
527                icon.paintIcon(mv, g,
528                        p.x - icon.getIconWidth() / 2,
529                        p.y - icon.getIconHeight() / 2);
530            }
531        }
532
533        if (currentPhoto >= 0 && currentPhoto < data.size()) {
534            ImageEntry e = data.get(currentPhoto);
535
536            if (e.getPos() != null) {
537                Point p = mv.getPoint(e.getPos());
538
539                int imgWidth;
540                int imgHeight;
541                if (useThumbs && e.hasThumbnail()) {
542                    Dimension d = scaledDimension(e.getThumbnail());
543                    imgWidth = d.width;
544                    imgHeight = d.height;
545                } else {
546                    imgWidth = selectedIcon.getIconWidth();
547                    imgHeight = selectedIcon.getIconHeight();
548                }
549
550                if (e.getExifImgDir() != null) {
551                    // Multiplier must be larger than sqrt(2)/2=0.71.
552                    double arrowlength = Math.max(25, Math.max(imgWidth, imgHeight) * 0.85);
553                    double arrowwidth = arrowlength / 1.4;
554
555                    double dir = e.getExifImgDir();
556                    // Rotate 90 degrees CCW
557                    double headdir = (dir < 90) ? dir + 270 : dir - 90;
558                    double leftdir = (headdir < 90) ? headdir + 270 : headdir - 90;
559                    double rightdir = (headdir > 270) ? headdir - 270 : headdir + 90;
560
561                    double ptx = p.x + Math.cos(Math.toRadians(headdir)) * arrowlength;
562                    double pty = p.y + Math.sin(Math.toRadians(headdir)) * arrowlength;
563
564                    double ltx = p.x + Math.cos(Math.toRadians(leftdir)) * arrowwidth/2;
565                    double lty = p.y + Math.sin(Math.toRadians(leftdir)) * arrowwidth/2;
566
567                    double rtx = p.x + Math.cos(Math.toRadians(rightdir)) * arrowwidth/2;
568                    double rty = p.y + Math.sin(Math.toRadians(rightdir)) * arrowwidth/2;
569
570                    g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
571                    g.setColor(new Color(255, 255, 255, 192));
572                    int[] xar = {(int) ltx, (int) ptx, (int) rtx, (int) ltx};
573                    int[] yar = {(int) lty, (int) pty, (int) rty, (int) lty};
574                    g.fillPolygon(xar, yar, 4);
575                    g.setColor(Color.black);
576                    g.setStroke(new BasicStroke(1.2f));
577                    g.drawPolyline(xar, yar, 3);
578                }
579
580                if (useThumbs && e.hasThumbnail()) {
581                    g.setColor(new Color(128, 0, 0, 122));
582                    g.fillRect(p.x - imgWidth / 2, p.y - imgHeight / 2, imgWidth, imgHeight);
583                } else {
584                    selectedIcon.paintIcon(mv, g,
585                            p.x - imgWidth / 2,
586                            p.y - imgHeight / 2);
587
588                }
589            }
590        }
591    }
592
593    @Override
594    public void visitBoundingBox(BoundingXYVisitor v) {
595        for (ImageEntry e : data) {
596            v.visit(e.getPos());
597        }
598    }
599
600    /**
601     * Shows next photo.
602     */
603    public void showNextPhoto() {
604        if (data != null && !data.isEmpty()) {
605            currentPhoto++;
606            if (currentPhoto >= data.size()) {
607                currentPhoto = data.size() - 1;
608            }
609            ImageViewerDialog.showImage(this, data.get(currentPhoto));
610        } else {
611            currentPhoto = -1;
612        }
613        Main.map.repaint();
614    }
615
616    /**
617     * Shows previous photo.
618     */
619    public void showPreviousPhoto() {
620        if (data != null && !data.isEmpty()) {
621            currentPhoto--;
622            if (currentPhoto < 0) {
623                currentPhoto = 0;
624            }
625            ImageViewerDialog.showImage(this, data.get(currentPhoto));
626        } else {
627            currentPhoto = -1;
628        }
629        Main.map.repaint();
630    }
631
632    /**
633     * Shows first photo.
634     */
635    public void showFirstPhoto() {
636        if (data != null && !data.isEmpty()) {
637            currentPhoto = 0;
638            ImageViewerDialog.showImage(this, data.get(currentPhoto));
639        } else {
640            currentPhoto = -1;
641        }
642        Main.map.repaint();
643    }
644
645    /**
646     * Shows last photo.
647     */
648    public void showLastPhoto() {
649        if (data != null && !data.isEmpty()) {
650            currentPhoto = data.size() - 1;
651            ImageViewerDialog.showImage(this, data.get(currentPhoto));
652        } else {
653            currentPhoto = -1;
654        }
655        Main.map.repaint();
656    }
657
658    public void checkPreviousNextButtons() {
659        ImageViewerDialog.setNextEnabled(data != null && currentPhoto < data.size() - 1);
660        ImageViewerDialog.setPreviousEnabled(currentPhoto > 0);
661    }
662
663    public void removeCurrentPhoto() {
664        if (data != null && !data.isEmpty() && currentPhoto >= 0 && currentPhoto < data.size()) {
665            data.remove(currentPhoto);
666            if (currentPhoto >= data.size()) {
667                currentPhoto = data.size() - 1;
668            }
669            if (currentPhoto >= 0) {
670                ImageViewerDialog.showImage(this, data.get(currentPhoto));
671            } else {
672                ImageViewerDialog.showImage(this, null);
673            }
674            updateOffscreenBuffer = true;
675            Main.map.repaint();
676        }
677    }
678
679    public void removeCurrentPhotoFromDisk() {
680        ImageEntry toDelete;
681        if (data != null && !data.isEmpty() && currentPhoto >= 0 && currentPhoto < data.size()) {
682            toDelete = data.get(currentPhoto);
683
684            int result = new ExtendedDialog(
685                    Main.parent,
686                    tr("Delete image file from disk"),
687                    new String[] {tr("Cancel"), tr("Delete")})
688            .setButtonIcons(new String[] {"cancel", "dialogs/delete"})
689            .setContent(new JLabel(tr("<html><h3>Delete the file {0} from disk?<p>The image file will be permanently lost!</h3></html>",
690                    toDelete.getFile().getName()), ImageProvider.get("dialogs/geoimage/deletefromdisk"), SwingConstants.LEFT))
691                    .toggleEnable("geoimage.deleteimagefromdisk")
692                    .setCancelButton(1)
693                    .setDefaultButton(2)
694                    .showDialog()
695                    .getValue();
696
697            if (result == 2) {
698                data.remove(currentPhoto);
699                if (currentPhoto >= data.size()) {
700                    currentPhoto = data.size() - 1;
701                }
702                if (currentPhoto >= 0) {
703                    ImageViewerDialog.showImage(this, data.get(currentPhoto));
704                } else {
705                    ImageViewerDialog.showImage(this, null);
706                }
707
708                if (Utils.deleteFile(toDelete.getFile())) {
709                    Main.info("File "+toDelete.getFile()+" deleted. ");
710                } else {
711                    JOptionPane.showMessageDialog(
712                            Main.parent,
713                            tr("Image file could not be deleted."),
714                            tr("Error"),
715                            JOptionPane.ERROR_MESSAGE
716                            );
717                }
718
719                updateOffscreenBuffer = true;
720                Main.map.repaint();
721            }
722        }
723    }
724
725    public void copyCurrentPhotoPath() {
726        if (data != null && !data.isEmpty() && currentPhoto >= 0 && currentPhoto < data.size()) {
727            Utils.copyToClipboard(data.get(currentPhoto).getFile().toString());
728        }
729    }
730
731    /**
732     * Removes a photo from the list of images by index.
733     * @param idx Image index
734     * @since 6392
735     */
736    public void removePhotoByIdx(int idx) {
737        if (idx >= 0 && data != null && idx < data.size()) {
738            data.remove(idx);
739        }
740    }
741
742    /**
743     * Returns the image that matches the position of the mouse event.
744     * @param evt Mouse event
745     * @return Image at mouse position, or {@code null} if there is no image at the mouse position
746     * @since 6392
747     */
748    public ImageEntry getPhotoUnderMouse(MouseEvent evt) {
749        if (data != null) {
750            for (int idx = data.size() - 1; idx >= 0; --idx) {
751                ImageEntry img = data.get(idx);
752                if (img.getPos() == null) {
753                    continue;
754                }
755                Point p = Main.map.mapView.getPoint(img.getPos());
756                Rectangle r;
757                if (useThumbs && img.hasThumbnail()) {
758                    Dimension d = scaledDimension(img.getThumbnail());
759                    r = new Rectangle(p.x - d.width / 2, p.y - d.height / 2, d.width, d.height);
760                } else {
761                    r = new Rectangle(p.x - icon.getIconWidth() / 2,
762                                      p.y - icon.getIconHeight() / 2,
763                                      icon.getIconWidth(),
764                                      icon.getIconHeight());
765                }
766                if (r.contains(evt.getPoint())) {
767                    return img;
768                }
769            }
770        }
771        return null;
772    }
773
774    /**
775     * Clears the currentPhoto, i.e. remove select marker, and optionally repaint.
776     * @param repaint Repaint flag
777     * @since 6392
778     */
779    public void clearCurrentPhoto(boolean repaint) {
780        currentPhoto = -1;
781        if (repaint) {
782            updateBufferAndRepaint();
783        }
784    }
785
786    /**
787     * Clears the currentPhoto of the other GeoImageLayer's. Otherwise there could be multiple selected photos.
788     */
789    private void clearOtherCurrentPhotos() {
790        for (GeoImageLayer layer:
791                 Main.getLayerManager().getLayersOfType(GeoImageLayer.class)) {
792            if (layer != this) {
793                layer.clearCurrentPhoto(false);
794            }
795        }
796    }
797
798    /**
799     * Registers a map mode for which the functionality of this layer should be available.
800     * @param mapMode Map mode to be registered
801     * @since 6392
802     */
803    public static void registerSupportedMapMode(MapMode mapMode) {
804        if (supportedMapModes == null) {
805            supportedMapModes = new ArrayList<>();
806        }
807        supportedMapModes.add(mapMode);
808    }
809
810    /**
811     * Determines if the functionality of this layer is available in
812     * the specified map mode. {@link SelectAction} and {@link LassoModeAction} are supported by default,
813     * other map modes can be registered.
814     * @param mapMode Map mode to be checked
815     * @return {@code true} if the map mode is supported,
816     *         {@code false} otherwise
817     */
818    private static boolean isSupportedMapMode(MapMode mapMode) {
819        if (mapMode instanceof SelectAction || mapMode instanceof LassoModeAction) {
820            return true;
821        }
822        if (supportedMapModes != null) {
823            for (MapMode supmmode: supportedMapModes) {
824                if (mapMode == supmmode) {
825                    return true;
826                }
827            }
828        }
829        return false;
830    }
831
832    @Override
833    public void hookUpMapView() {
834        mouseAdapter = new MouseAdapter() {
835            private boolean isMapModeOk() {
836                return Main.map.mapMode == null || isSupportedMapMode(Main.map.mapMode);
837            }
838
839            @Override
840            public void mousePressed(MouseEvent e) {
841                if (e.getButton() != MouseEvent.BUTTON1)
842                    return;
843                if (isVisible() && isMapModeOk()) {
844                    Main.map.mapView.repaint();
845                }
846            }
847
848            @Override
849            public void mouseReleased(MouseEvent ev) {
850                if (ev.getButton() != MouseEvent.BUTTON1)
851                    return;
852                if (data == null || !isVisible() || !isMapModeOk())
853                    return;
854
855                for (int i = data.size() - 1; i >= 0; --i) {
856                    ImageEntry e = data.get(i);
857                    if (e.getPos() == null) {
858                        continue;
859                    }
860                    Point p = Main.map.mapView.getPoint(e.getPos());
861                    Rectangle r;
862                    if (useThumbs && e.hasThumbnail()) {
863                        Dimension d = scaledDimension(e.getThumbnail());
864                        r = new Rectangle(p.x - d.width / 2, p.y - d.height / 2, d.width, d.height);
865                    } else {
866                        r = new Rectangle(p.x - icon.getIconWidth() / 2,
867                                p.y - icon.getIconHeight() / 2,
868                                icon.getIconWidth(),
869                                icon.getIconHeight());
870                    }
871                    if (r.contains(ev.getPoint())) {
872                        clearOtherCurrentPhotos();
873                        currentPhoto = i;
874                        ImageViewerDialog.showImage(GeoImageLayer.this, e);
875                        Main.map.repaint();
876                        break;
877                    }
878                }
879            }
880        };
881
882        mapModeListener = new MapModeChangeListener() {
883            @Override
884            public void mapModeChange(MapMode oldMapMode, MapMode newMapMode) {
885                if (newMapMode == null || isSupportedMapMode(newMapMode)) {
886                    Main.map.mapView.addMouseListener(mouseAdapter);
887                } else {
888                    Main.map.mapView.removeMouseListener(mouseAdapter);
889                }
890            }
891        };
892
893        MapFrame.addMapModeChangeListener(mapModeListener);
894        mapModeListener.mapModeChange(null, Main.map.mapMode);
895
896        Main.getLayerManager().addActiveLayerChangeListener(new ActiveLayerChangeListener() {
897            @Override
898            public void activeOrEditLayerChanged(ActiveLayerChangeEvent e) {
899                if (Main.getLayerManager().getActiveLayer() == GeoImageLayer.this) {
900                    // only in select mode it is possible to click the images
901                    Main.map.selectSelectTool(false);
902                }
903            }
904        });
905
906        Main.getLayerManager().addLayerChangeListener(new LayerChangeListener() {
907            @Override
908            public void layerAdded(LayerAddEvent e) {
909                // Do nothing
910            }
911
912            @Override
913            public void layerRemoving(LayerRemoveEvent e) {
914                if (e.getRemovedLayer() == GeoImageLayer.this) {
915                    stopLoadThumbs();
916                    Main.map.mapView.removeMouseListener(mouseAdapter);
917                    MapFrame.removeMapModeChangeListener(mapModeListener);
918                    currentPhoto = -1;
919                    if (data != null) {
920                        data.clear();
921                    }
922                    data = null;
923                    // stop listening to layer change events
924                    Main.getLayerManager().removeLayerChangeListener(this);
925                }
926            }
927
928            @Override
929            public void layerOrderChanged(LayerOrderChangeEvent e) {
930                // Do nothing
931            }
932        });
933
934        Main.map.mapView.addPropertyChangeListener(this);
935        if (Main.map.getToggleDialog(ImageViewerDialog.class) == null) {
936            ImageViewerDialog.newInstance();
937            Main.map.addToggleDialog(ImageViewerDialog.getInstance());
938        }
939    }
940
941    @Override
942    public void propertyChange(PropertyChangeEvent evt) {
943        if (NavigatableComponent.PROPNAME_CENTER.equals(evt.getPropertyName()) ||
944                NavigatableComponent.PROPNAME_SCALE.equals(evt.getPropertyName())) {
945            updateOffscreenBuffer = true;
946        }
947    }
948
949    /**
950     * Start to load thumbnails.
951     */
952    public synchronized void startLoadThumbs() {
953        if (useThumbs && !thumbsLoaded && !thumbsLoaderRunning) {
954            stopLoadThumbs();
955            thumbsloader = new ThumbsLoader(this);
956            thumbsLoaderExecutor.submit(thumbsloader);
957            thumbsLoaderRunning = true;
958        }
959    }
960
961    /**
962     * Stop to load thumbnails.
963     *
964     * Can be called at any time to make sure that the
965     * thumbnail loader is stopped.
966     */
967    public synchronized void stopLoadThumbs() {
968        if (thumbsloader != null) {
969            thumbsloader.stop = true;
970        }
971        thumbsLoaderRunning = false;
972    }
973
974    /**
975     * Called to signal that the loading of thumbnails has finished.
976     *
977     * Usually called from {@link ThumbsLoader} in another thread.
978     */
979    public void thumbsLoaded() {
980        thumbsLoaded = true;
981    }
982
983    public void updateBufferAndRepaint() {
984        updateOffscreenBuffer = true;
985        invalidate();
986    }
987
988    /**
989     * Get list of images in layer.
990     * @return List of images in layer
991     */
992    public List<ImageEntry> getImages() {
993        return data == null ? Collections.<ImageEntry>emptyList() : new ArrayList<>(data);
994    }
995
996    /**
997     * Returns the associated GPX layer.
998     * @return The associated GPX layer
999     */
1000    public GpxLayer getGpxLayer() {
1001        return gpxLayer;
1002    }
1003
1004    @Override
1005    public void jumpToNextMarker() {
1006        showNextPhoto();
1007    }
1008
1009    @Override
1010    public void jumpToPreviousMarker() {
1011        showPreviousPhoto();
1012    }
1013
1014    /**
1015     * Returns the current thumbnail display status.
1016     * {@code true}: thumbnails are displayed, {@code false}: an icon is displayed instead of thumbnails.
1017     * @return Current thumbnail display status
1018     * @since 6392
1019     */
1020    public boolean isUseThumbs() {
1021        return useThumbs;
1022    }
1023
1024    /**
1025     * Enables or disables the display of thumbnails.  Does not update the display.
1026     * @param useThumbs New thumbnail display status
1027     * @since 6392
1028     */
1029    public void setUseThumbs(boolean useThumbs) {
1030        this.useThumbs = useThumbs;
1031        if (useThumbs && !thumbsLoaded) {
1032            startLoadThumbs();
1033        } else if (!useThumbs) {
1034            stopLoadThumbs();
1035        }
1036    }
1037}