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