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