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