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    /**
359     * Prepare the string that is displayed if layer information is requested.
360     * @return String with layer information
361     */
362    private String infoText() {
363        int tagged = 0;
364        int newdata = 0;
365        for (ImageEntry e : data) {
366            if (e.getPos() != null) {
367                tagged++;
368            }
369            if (e.hasNewGpsData()) {
370                newdata++;
371            }
372        }
373        return "<html>"
374                + trn("{0} image loaded.", "{0} images loaded.", data.size(), data.size())
375                + " " + trn("{0} was found to be GPS tagged.", "{0} were found to be GPS tagged.", tagged, tagged)
376                + (newdata > 0 ? "<br>" + trn("{0} has updated GPS data.", "{0} have updated GPS data.", newdata, newdata) : "")
377                + "</html>";
378    }
379
380    @Override public Object getInfoComponent() {
381        return infoText();
382    }
383
384    @Override
385    public String getToolTipText() {
386        return infoText();
387    }
388
389    @Override
390    public boolean isMergable(Layer other) {
391        return other instanceof GeoImageLayer;
392    }
393
394    @Override
395    public void mergeFrom(Layer from) {
396        GeoImageLayer l = (GeoImageLayer) from;
397
398        // Stop to load thumbnails on both layers.  Thumbnail loading will continue the next time
399        // the layer is painted.
400        stopLoadThumbs();
401        l.stopLoadThumbs();
402
403        final ImageEntry selected = l.currentPhoto >= 0 ? l.data.get(l.currentPhoto) : null;
404
405        data.addAll(l.data);
406        Collections.sort(data);
407
408        // Supress the double photos.
409        if (data.size() > 1) {
410            ImageEntry cur;
411            ImageEntry prev = data.get(data.size() - 1);
412            for (int i = data.size() - 2; i >= 0; i--) {
413                cur = data.get(i);
414                if (cur.getFile().equals(prev.getFile())) {
415                    data.remove(i);
416                } else {
417                    prev = cur;
418                }
419            }
420        }
421
422        if (selected != null && !data.isEmpty()) {
423            GuiHelper.runInEDTAndWait(new Runnable() {
424                @Override
425                public void run() {
426                    for (int i = 0; i < data.size() ; i++) {
427                        if (selected.equals(data.get(i))) {
428                            currentPhoto = i;
429                            ImageViewerDialog.showImage(GeoImageLayer.this, data.get(i));
430                            break;
431                        }
432                    }
433                }
434            });
435        }
436
437        setName(l.getName());
438        thumbsLoaded &= l.thumbsLoaded;
439    }
440
441    private Dimension scaledDimension(Image thumb) {
442        final double d = Main.map.mapView.getDist100Pixel();
443        final double size = 10 /*meter*/;     /* size of the photo on the map */
444        double s = size * 100 /*px*/ / d;
445
446        final double sMin = ThumbsLoader.minSize;
447        final double sMax = ThumbsLoader.maxSize;
448
449        if (s < sMin) {
450            s = sMin;
451        }
452        if (s > sMax) {
453            s = sMax;
454        }
455        final double f = s / sMax;  /* scale factor */
456
457        if (thumb == null)
458            return null;
459
460        return new Dimension(
461                (int) Math.round(f * thumb.getWidth(null)),
462                (int) Math.round(f * thumb.getHeight(null)));
463    }
464
465    @Override
466    public void paint(Graphics2D g, MapView mv, Bounds bounds) {
467        int width = mv.getWidth();
468        int height = mv.getHeight();
469        Rectangle clip = g.getClipBounds();
470        if (useThumbs) {
471            if (!thumbsLoaded) {
472                startLoadThumbs();
473            }
474
475            if (null == offscreenBuffer || offscreenBuffer.getWidth() != width  // reuse the old buffer if possible
476                    || offscreenBuffer.getHeight() != height) {
477                offscreenBuffer = new BufferedImage(width, height,
478                        BufferedImage.TYPE_INT_ARGB);
479                updateOffscreenBuffer = true;
480            }
481
482            if (updateOffscreenBuffer) {
483                Graphics2D tempG = offscreenBuffer.createGraphics();
484                tempG.setColor(new Color(0,0,0,0));
485                Composite saveComp = tempG.getComposite();
486                tempG.setComposite(AlphaComposite.Clear);   // remove the old images
487                tempG.fillRect(0, 0, width, height);
488                tempG.setComposite(saveComp);
489
490                for (ImageEntry e : data) {
491                    if (e.getPos() == null) {
492                        continue;
493                    }
494                    Point p = mv.getPoint(e.getPos());
495                    if (e.thumbnail != null) {
496                        Dimension d = scaledDimension(e.thumbnail);
497                        Rectangle target = new Rectangle(p.x - d.width / 2, p.y - d.height / 2, d.width, d.height);
498                        if (clip.intersects(target)) {
499                            tempG.drawImage(e.thumbnail, target.x, target.y, target.width, target.height, null);
500                        }
501                    }
502                    else { // thumbnail not loaded yet
503                        icon.paintIcon(mv, tempG,
504                                p.x - icon.getIconWidth() / 2,
505                                p.y - icon.getIconHeight() / 2);
506                    }
507                }
508                updateOffscreenBuffer = false;
509            }
510            g.drawImage(offscreenBuffer, 0, 0, null);
511        }
512        else {
513            for (ImageEntry e : data) {
514                if (e.getPos() == null) {
515                    continue;
516                }
517                Point p = mv.getPoint(e.getPos());
518                icon.paintIcon(mv, g,
519                        p.x - icon.getIconWidth() / 2,
520                        p.y - icon.getIconHeight() / 2);
521            }
522        }
523
524        if (currentPhoto >= 0 && currentPhoto < data.size()) {
525            ImageEntry e = data.get(currentPhoto);
526
527            if (e.getPos() != null) {
528                Point p = mv.getPoint(e.getPos());
529
530                int imgWidth = 100;
531                int imgHeight = 100;
532                if (useThumbs && e.thumbnail != null) {
533                    Dimension d = scaledDimension(e.thumbnail);
534                    imgWidth = d.width;
535                    imgHeight = d.height;
536                }
537                else {
538                    imgWidth = selectedIcon.getIconWidth();
539                    imgHeight = selectedIcon.getIconHeight();
540                }
541
542                if (e.getExifImgDir() != null) {
543                    // Multiplier must be larger than sqrt(2)/2=0.71.
544                    double arrowlength = Math.max(25, Math.max(imgWidth, imgHeight) * 0.85);
545                    double arrowwidth = arrowlength / 1.4;
546
547                    double dir = e.getExifImgDir();
548                    // Rotate 90 degrees CCW
549                    double headdir = ( dir < 90 ) ? dir + 270 : dir - 90;
550                    double leftdir = ( headdir < 90 ) ? headdir + 270 : headdir - 90;
551                    double rightdir = ( headdir > 270 ) ? headdir - 270 : headdir + 90;
552
553                    double ptx = p.x + Math.cos(Math.toRadians(headdir)) * arrowlength;
554                    double pty = p.y + Math.sin(Math.toRadians(headdir)) * arrowlength;
555
556                    double ltx = p.x + Math.cos(Math.toRadians(leftdir)) * arrowwidth/2;
557                    double lty = p.y + Math.sin(Math.toRadians(leftdir)) * arrowwidth/2;
558
559                    double rtx = p.x + Math.cos(Math.toRadians(rightdir)) * arrowwidth/2;
560                    double rty = p.y + Math.sin(Math.toRadians(rightdir)) * arrowwidth/2;
561
562                    g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
563                    g.setColor(new Color(255, 255, 255, 192));
564                    int[] xar = {(int) ltx, (int) ptx, (int) rtx, (int) ltx};
565                    int[] yar = {(int) lty, (int) pty, (int) rty, (int) lty};
566                    g.fillPolygon(xar, yar, 4);
567                    g.setColor(Color.black);
568                    g.setStroke(new BasicStroke(1.2f));
569                    g.drawPolyline(xar, yar, 3);
570                }
571
572                if (useThumbs && e.thumbnail != null) {
573                    g.setColor(new Color(128, 0, 0, 122));
574                    g.fillRect(p.x - imgWidth / 2, p.y - imgHeight / 2, imgWidth, imgHeight);
575                } else {
576                    selectedIcon.paintIcon(mv, g,
577                            p.x - imgWidth / 2,
578                            p.y - imgHeight / 2);
579
580                }
581            }
582        }
583    }
584
585    @Override
586    public void visitBoundingBox(BoundingXYVisitor v) {
587        for (ImageEntry e : data) {
588            v.visit(e.getPos());
589        }
590    }
591
592    /**
593     * Extract GPS metadata from image EXIF
594     *
595     * If successful, fills in the LatLon and EastNorth attributes of passed in image
596     */
597    private static void extractExif(ImageEntry e) {
598
599        Metadata metadata;
600        Directory dirExif;
601        GpsDirectory dirGps;
602
603        try {
604            metadata = JpegMetadataReader.readMetadata(e.getFile());
605            dirExif = metadata.getDirectory(ExifIFD0Directory.class);
606            dirGps = metadata.getDirectory(GpsDirectory.class);
607        } catch (CompoundException | IOException p) {
608            e.setExifCoor(null);
609            e.setPos(null);
610            return;
611        }
612
613        try {
614            if (dirExif != null) {
615                int orientation = dirExif.getInt(ExifIFD0Directory.TAG_ORIENTATION);
616                e.setExifOrientation(orientation);
617            }
618        } catch (MetadataException ex) {
619            Main.debug(ex.getMessage());
620        }
621
622        if (dirGps == null) {
623            e.setExifCoor(null);
624            e.setPos(null);
625            return;
626        }
627
628        try {
629            double speed = dirGps.getDouble(GpsDirectory.TAG_GPS_SPEED);
630            String speedRef = dirGps.getString(GpsDirectory.TAG_GPS_SPEED_REF);
631            if (speedRef != null) {
632                if (speedRef.equalsIgnoreCase("M")) {
633                    // miles per hour
634                    speed *= 1.609344;
635                } else if (speedRef.equalsIgnoreCase("N")) {
636                    // knots == nautical miles per hour
637                    speed *= 1.852;
638                }
639                // default is K (km/h)
640            }
641            e.setSpeed(speed);
642        } catch (Exception ex) {
643            Main.debug(ex.getMessage());
644        }
645
646        try {
647            double ele = dirGps.getDouble(GpsDirectory.TAG_GPS_ALTITUDE);
648            int d = dirGps.getInt(GpsDirectory.TAG_GPS_ALTITUDE_REF);
649            if (d == 1) {
650                ele *= -1;
651            }
652            e.setElevation(ele);
653        } catch (MetadataException ex) {
654            Main.debug(ex.getMessage());
655        }
656
657        try {
658            LatLon latlon = ExifReader.readLatLon(dirGps);
659            e.setExifCoor(latlon);
660            e.setPos(e.getExifCoor());
661
662        } catch (Exception ex) { // (other exceptions, e.g. #5271)
663            Main.error("Error reading EXIF from file: "+ex);
664            e.setExifCoor(null);
665            e.setPos(null);
666        }
667
668        try {
669            Double direction = ExifReader.readDirection(dirGps);
670            if (direction != null) {
671                e.setExifImgDir(direction.doubleValue());
672            }
673        } catch (Exception ex) { // (CompoundException and other exceptions, e.g. #5271)
674            Main.debug(ex.getMessage());
675        }
676
677        // Time and date. We can have these cases:
678        // 1) GPS_TIME_STAMP not set -> date/time will be null
679        // 2) GPS_DATE_STAMP not set -> use EXIF date or set to default
680        // 3) GPS_TIME_STAMP and GPS_DATE_STAMP are set
681        int[] timeStampComps = dirGps.getIntArray(GpsDirectory.TAG_GPS_TIME_STAMP);
682        if (timeStampComps != null) {
683            int gpsHour = timeStampComps[0];
684            int gpsMin = timeStampComps[1];
685            int gpsSec = timeStampComps[2];
686            Calendar cal = new GregorianCalendar(TimeZone.getTimeZone("UTC"));
687
688            // We have the time. Next step is to check if the GPS date stamp is set.
689            // dirGps.getString() always succeeds, but the return value might be null.
690            String dateStampStr = dirGps.getString(GpsDirectory.TAG_GPS_DATE_STAMP);
691            if (dateStampStr != null && dateStampStr.matches("^\\d+:\\d+:\\d+$")) {
692                String[] dateStampComps = dateStampStr.split(":");
693                cal.set(Calendar.YEAR, Integer.parseInt(dateStampComps[0]));
694                cal.set(Calendar.MONTH, Integer.parseInt(dateStampComps[1]) - 1);
695                cal.set(Calendar.DAY_OF_MONTH, Integer.parseInt(dateStampComps[2]));
696            }
697            else {
698                // No GPS date stamp in EXIF data. Copy it from EXIF time.
699                // Date is not set if EXIF time is not available.
700                if (e.hasExifTime()) {
701                    // Time not set yet, so we can copy everything, not just date.
702                    cal.setTime(e.getExifTime());
703                }
704            }
705
706            cal.set(Calendar.HOUR_OF_DAY, gpsHour);
707            cal.set(Calendar.MINUTE, gpsMin);
708            cal.set(Calendar.SECOND, gpsSec);
709
710            e.setExifGpsTime(cal.getTime());
711        }
712    }
713
714    public void showNextPhoto() {
715        if (data != null && data.size() > 0) {
716            currentPhoto++;
717            if (currentPhoto >= data.size()) {
718                currentPhoto = data.size() - 1;
719            }
720            ImageViewerDialog.showImage(this, data.get(currentPhoto));
721        } else {
722            currentPhoto = -1;
723        }
724        Main.map.repaint();
725    }
726
727    public void showPreviousPhoto() {
728        if (data != null && !data.isEmpty()) {
729            currentPhoto--;
730            if (currentPhoto < 0) {
731                currentPhoto = 0;
732            }
733            ImageViewerDialog.showImage(this, data.get(currentPhoto));
734        } else {
735            currentPhoto = -1;
736        }
737        Main.map.repaint();
738    }
739
740    public void showFirstPhoto() {
741        if (data != null && data.size() > 0) {
742            currentPhoto = 0;
743            ImageViewerDialog.showImage(this, data.get(currentPhoto));
744        } else {
745            currentPhoto = -1;
746        }
747        Main.map.repaint();
748    }
749
750    public void showLastPhoto() {
751        if (data != null && data.size() > 0) {
752            currentPhoto = data.size() - 1;
753            ImageViewerDialog.showImage(this, data.get(currentPhoto));
754        } else {
755            currentPhoto = -1;
756        }
757        Main.map.repaint();
758    }
759
760    public void checkPreviousNextButtons() {
761        ImageViewerDialog.setNextEnabled(currentPhoto < data.size() - 1);
762        ImageViewerDialog.setPreviousEnabled(currentPhoto > 0);
763    }
764
765    public void removeCurrentPhoto() {
766        if (data != null && data.size() > 0 && currentPhoto >= 0 && currentPhoto < data.size()) {
767            data.remove(currentPhoto);
768            if (currentPhoto >= data.size()) {
769                currentPhoto = data.size() - 1;
770            }
771            if (currentPhoto >= 0) {
772                ImageViewerDialog.showImage(this, data.get(currentPhoto));
773            } else {
774                ImageViewerDialog.showImage(this, null);
775            }
776            updateOffscreenBuffer = true;
777            Main.map.repaint();
778        }
779    }
780
781    public void removeCurrentPhotoFromDisk() {
782        ImageEntry toDelete = null;
783        if (data != null && data.size() > 0 && currentPhoto >= 0 && currentPhoto < data.size()) {
784            toDelete = data.get(currentPhoto);
785
786            int result = new ExtendedDialog(
787                    Main.parent,
788                    tr("Delete image file from disk"),
789                    new String[] {tr("Cancel"), tr("Delete")})
790            .setButtonIcons(new String[] {"cancel", "dialogs/delete"})
791            .setContent(new JLabel(tr("<html><h3>Delete the file {0} from disk?<p>The image file will be permanently lost!</h3></html>"
792                    ,toDelete.getFile().getName()), ImageProvider.get("dialogs/geoimage/deletefromdisk"),SwingConstants.LEFT))
793                    .toggleEnable("geoimage.deleteimagefromdisk")
794                    .setCancelButton(1)
795                    .setDefaultButton(2)
796                    .showDialog()
797                    .getValue();
798
799            if(result == 2)
800            {
801                data.remove(currentPhoto);
802                if (currentPhoto >= data.size()) {
803                    currentPhoto = data.size() - 1;
804                }
805                if (currentPhoto >= 0) {
806                    ImageViewerDialog.showImage(this, data.get(currentPhoto));
807                } else {
808                    ImageViewerDialog.showImage(this, null);
809                }
810
811                if (toDelete.getFile().delete()) {
812                    Main.info("File "+toDelete.getFile().toString()+" deleted. ");
813                } else {
814                    JOptionPane.showMessageDialog(
815                            Main.parent,
816                            tr("Image file could not be deleted."),
817                            tr("Error"),
818                            JOptionPane.ERROR_MESSAGE
819                            );
820                }
821
822                updateOffscreenBuffer = true;
823                Main.map.repaint();
824            }
825        }
826    }
827
828    public void copyCurrentPhotoPath() {
829        ImageEntry toCopy = null;
830        if (data != null && data.size() > 0 && currentPhoto >= 0 && currentPhoto < data.size()) {
831            toCopy = data.get(currentPhoto);
832            String copyString = toCopy.getFile().toString();
833            Utils.copyToClipboard(copyString);
834        }
835    }
836
837    /**
838     * Removes a photo from the list of images by index.
839     * @param idx Image index
840     * @since 6392
841     */
842    public void removePhotoByIdx(int idx) {
843        if (idx >= 0 && data != null && idx < data.size()) {
844            data.remove(idx);
845        }
846    }
847
848    /**
849     * Returns the image that matches the position of the mouse event.
850     * @param evt Mouse event
851     * @return Image at mouse position, or {@code null} if there is no image at the mouse position
852     * @since 6392
853     */
854    public ImageEntry getPhotoUnderMouse(MouseEvent evt) {
855        if (data != null) {
856            for (int idx = data.size() - 1; idx >= 0; --idx) {
857                ImageEntry img = data.get(idx);
858                if (img.getPos() == null) {
859                    continue;
860                }
861                Point p = Main.map.mapView.getPoint(img.getPos());
862                Rectangle r;
863                if (useThumbs && img.thumbnail != null) {
864                    Dimension d = scaledDimension(img.thumbnail);
865                    r = new Rectangle(p.x - d.width / 2, p.y - d.height / 2, d.width, d.height);
866                } else {
867                    r = new Rectangle(p.x - icon.getIconWidth() / 2,
868                                      p.y - icon.getIconHeight() / 2,
869                                      icon.getIconWidth(),
870                                      icon.getIconHeight());
871                }
872                if (r.contains(evt.getPoint())) {
873                    return img;
874                }
875            }
876        }
877        return null;
878    }
879
880    /**
881     * Clears the currentPhoto, i.e. remove select marker, and optionally repaint.
882     * @param repaint Repaint flag
883     * @since 6392
884     */
885    public void clearCurrentPhoto(boolean repaint) {
886        currentPhoto = -1;
887        if (repaint) {
888            updateBufferAndRepaint();
889        }
890    }
891
892    /**
893     * Clears the currentPhoto of the other GeoImageLayer's. Otherwise there could be multiple selected photos.
894     */
895    private void clearOtherCurrentPhotos() {
896        for (GeoImageLayer layer:
897                 Main.map.mapView.getLayersOfType(GeoImageLayer.class)) {
898            if (layer != this) {
899                layer.clearCurrentPhoto(false);
900            }
901        }
902    }
903
904    private static List<MapMode> supportedMapModes = null;
905
906    /**
907     * Registers a map mode for which the functionality of this layer should be available.
908     * @param mapMode Map mode to be registered
909     * @since 6392
910     */
911    public static void registerSupportedMapMode(MapMode mapMode) {
912        if (supportedMapModes == null) {
913            supportedMapModes = new ArrayList<>();
914        }
915        supportedMapModes.add(mapMode);
916    }
917
918    /**
919     * Determines if the functionality of this layer is available in
920     * the specified map mode. {@link SelectAction} and {@link LassoModeAction} are supported by default,
921     * other map modes can be registered.
922     * @param mapMode Map mode to be checked
923     * @return {@code true} if the map mode is supported,
924     *         {@code false} otherwise
925     */
926    private static final boolean isSupportedMapMode(MapMode mapMode) {
927        if (mapMode instanceof SelectAction || mapMode instanceof LassoModeAction) {
928            return true;
929        }
930        if (supportedMapModes != null) {
931            for (MapMode supmmode: supportedMapModes) {
932                if (mapMode == supmmode) {
933                    return true;
934                }
935            }
936        }
937        return false;
938    }
939
940    private MouseAdapter mouseAdapter = null;
941    private MapModeChangeListener mapModeListener = null;
942
943    @Override
944    public void hookUpMapView() {
945        mouseAdapter = new MouseAdapter() {
946            private final boolean isMapModeOk() {
947                return Main.map.mapMode == null || isSupportedMapMode(Main.map.mapMode);
948            }
949            @Override public void mousePressed(MouseEvent e) {
950
951                if (e.getButton() != MouseEvent.BUTTON1)
952                    return;
953                if (isVisible() && isMapModeOk()) {
954                    Main.map.mapView.repaint();
955                }
956            }
957
958            @Override public void mouseReleased(MouseEvent ev) {
959                if (ev.getButton() != MouseEvent.BUTTON1)
960                    return;
961                if (data == null || !isVisible() || !isMapModeOk())
962                    return;
963
964                for (int i = data.size() - 1; i >= 0; --i) {
965                    ImageEntry e = data.get(i);
966                    if (e.getPos() == null) {
967                        continue;
968                    }
969                    Point p = Main.map.mapView.getPoint(e.getPos());
970                    Rectangle r;
971                    if (useThumbs && e.thumbnail != null) {
972                        Dimension d = scaledDimension(e.thumbnail);
973                        r = new Rectangle(p.x - d.width / 2, p.y - d.height / 2, d.width, d.height);
974                    } else {
975                        r = new Rectangle(p.x - icon.getIconWidth() / 2,
976                                p.y - icon.getIconHeight() / 2,
977                                icon.getIconWidth(),
978                                icon.getIconHeight());
979                    }
980                    if (r.contains(ev.getPoint())) {
981                        clearOtherCurrentPhotos();
982                        currentPhoto = i;
983                        ImageViewerDialog.showImage(GeoImageLayer.this, e);
984                        Main.map.repaint();
985                        break;
986                    }
987                }
988            }
989        };
990
991        mapModeListener = new MapModeChangeListener() {
992            @Override
993            public void mapModeChange(MapMode oldMapMode, MapMode newMapMode) {
994                if (newMapMode == null || isSupportedMapMode(newMapMode)) {
995                    Main.map.mapView.addMouseListener(mouseAdapter);
996                } else {
997                    Main.map.mapView.removeMouseListener(mouseAdapter);
998                }
999            }
1000        };
1001
1002        MapFrame.addMapModeChangeListener(mapModeListener);
1003        mapModeListener.mapModeChange(null, Main.map.mapMode);
1004
1005        MapView.addLayerChangeListener(new LayerChangeListener() {
1006            @Override
1007            public void activeLayerChange(Layer oldLayer, Layer newLayer) {
1008                if (newLayer == GeoImageLayer.this) {
1009                    // only in select mode it is possible to click the images
1010                    Main.map.selectSelectTool(false);
1011                }
1012            }
1013
1014            @Override
1015            public void layerAdded(Layer newLayer) {
1016            }
1017
1018            @Override
1019            public void layerRemoved(Layer oldLayer) {
1020                if (oldLayer == GeoImageLayer.this) {
1021                    stopLoadThumbs();
1022                    Main.map.mapView.removeMouseListener(mouseAdapter);
1023                    MapFrame.removeMapModeChangeListener(mapModeListener);
1024                    currentPhoto = -1;
1025                    data.clear();
1026                    data = null;
1027                    // stop listening to layer change events
1028                    MapView.removeLayerChangeListener(this);
1029                }
1030            }
1031        });
1032
1033        Main.map.mapView.addPropertyChangeListener(this);
1034        if (Main.map.getToggleDialog(ImageViewerDialog.class) == null) {
1035            ImageViewerDialog.newInstance();
1036            Main.map.addToggleDialog(ImageViewerDialog.getInstance());
1037        }
1038    }
1039
1040    @Override
1041    public void propertyChange(PropertyChangeEvent evt) {
1042        if (NavigatableComponent.PROPNAME_CENTER.equals(evt.getPropertyName()) || NavigatableComponent.PROPNAME_SCALE.equals(evt.getPropertyName())) {
1043            updateOffscreenBuffer = true;
1044        }
1045    }
1046
1047    /**
1048     * Start to load thumbnails.
1049     */
1050    public synchronized void startLoadThumbs() {
1051        if (useThumbs && !thumbsLoaded && !thumbsLoaderRunning) {
1052            stopLoadThumbs();
1053            thumbsloader = new ThumbsLoader(this);
1054            thumbsLoaderExecutor.submit(thumbsloader);
1055            thumbsLoaderRunning = true;
1056        }
1057    }
1058
1059    /**
1060     * Stop to load thumbnails.
1061     *
1062     * Can be called at any time to make sure that the
1063     * thumbnail loader is stopped.
1064     */
1065    public synchronized void stopLoadThumbs() {
1066        if (thumbsloader != null) {
1067            thumbsloader.stop = true;
1068        }
1069        thumbsLoaderRunning = false;
1070    }
1071
1072    /**
1073     * Called to signal that the loading of thumbnails has finished.
1074     *
1075     * Usually called from {@link ThumbsLoader} in another thread.
1076     */
1077    public void thumbsLoaded() {
1078        thumbsLoaded = true;
1079    }
1080
1081    public void updateBufferAndRepaint() {
1082        updateOffscreenBuffer = true;
1083        Main.map.mapView.repaint();
1084    }
1085
1086    /**
1087     * Get list of images in layer.
1088     * @return List of images in layer
1089     */
1090    public List<ImageEntry> getImages() {
1091        List<ImageEntry> copy = new ArrayList<>(data.size());
1092        for (ImageEntry ie : data) {
1093            copy.add(ie);
1094        }
1095        return copy;
1096    }
1097
1098    /**
1099     * Returns the associated GPX layer.
1100     * @return The associated GPX layer
1101     */
1102    public GpxLayer getGpxLayer() {
1103        return gpxLayer;
1104    }
1105
1106    @Override
1107    public void jumpToNextMarker() {
1108        showNextPhoto();
1109    }
1110
1111    @Override
1112    public void jumpToPreviousMarker() {
1113        showPreviousPhoto();
1114    }
1115
1116    /**
1117     * Returns the current thumbnail display status.
1118     * {@code true}: thumbnails are displayed, {@code false}: an icon is displayed instead of thumbnails.
1119     * @return Current thumbnail display status
1120     * @since 6392
1121     */
1122    public boolean isUseThumbs() {
1123        return useThumbs;
1124    }
1125
1126    /**
1127     * Enables or disables the display of thumbnails.  Does not update the display.
1128     * @param useThumbs New thumbnail display status
1129     * @since 6392
1130     */
1131    public void setUseThumbs(boolean useThumbs) {
1132        this.useThumbs = useThumbs;
1133        if (useThumbs && !thumbsLoaded) {
1134            startLoadThumbs();
1135        } else if (!useThumbs) {
1136            stopLoadThumbs();
1137        }
1138    }
1139}