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