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