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