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