001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.gui.layer.geoimage;
003
004import static java.util.stream.Collectors.toList;
005import static org.openstreetmap.josm.tools.I18n.tr;
006import static org.openstreetmap.josm.tools.I18n.trn;
007
008import java.awt.AlphaComposite;
009import java.awt.BasicStroke;
010import java.awt.Color;
011import java.awt.Composite;
012import java.awt.Dimension;
013import java.awt.Graphics2D;
014import java.awt.Image;
015import java.awt.Point;
016import java.awt.Rectangle;
017import java.awt.RenderingHints;
018import java.awt.event.MouseAdapter;
019import java.awt.event.MouseEvent;
020import java.awt.event.MouseMotionAdapter;
021import java.awt.image.BufferedImage;
022import java.io.File;
023import java.util.ArrayList;
024import java.util.Arrays;
025import java.util.Collection;
026import java.util.Collections;
027import java.util.Comparator;
028import java.util.LinkedList;
029import java.util.List;
030import java.util.Objects;
031import java.util.concurrent.ExecutorService;
032import java.util.concurrent.Executors;
033
034import javax.swing.Action;
035import javax.swing.Icon;
036
037import org.openstreetmap.josm.actions.AutoScaleAction;
038import org.openstreetmap.josm.actions.ExpertToggleAction;
039import org.openstreetmap.josm.actions.RenameLayerAction;
040import org.openstreetmap.josm.actions.mapmode.MapMode;
041import org.openstreetmap.josm.actions.mapmode.SelectAction;
042import org.openstreetmap.josm.actions.mapmode.SelectLassoAction;
043import org.openstreetmap.josm.data.Bounds;
044import org.openstreetmap.josm.data.Data;
045import org.openstreetmap.josm.data.ImageData;
046import org.openstreetmap.josm.data.ImageData.ImageDataUpdateListener;
047import org.openstreetmap.josm.data.gpx.GpxData;
048import org.openstreetmap.josm.data.gpx.GpxImageEntry;
049import org.openstreetmap.josm.data.gpx.GpxTrack;
050import org.openstreetmap.josm.data.osm.visitor.BoundingXYVisitor;
051import org.openstreetmap.josm.gui.MainApplication;
052import org.openstreetmap.josm.gui.MapFrame;
053import org.openstreetmap.josm.gui.MapFrame.MapModeChangeListener;
054import org.openstreetmap.josm.gui.MapView;
055import org.openstreetmap.josm.gui.NavigatableComponent;
056import org.openstreetmap.josm.gui.dialogs.LayerListDialog;
057import org.openstreetmap.josm.gui.dialogs.LayerListPopup;
058import org.openstreetmap.josm.gui.layer.AbstractModifiableLayer;
059import org.openstreetmap.josm.gui.layer.GpxLayer;
060import org.openstreetmap.josm.gui.layer.JumpToMarkerActions.JumpToMarkerLayer;
061import org.openstreetmap.josm.gui.layer.JumpToMarkerActions.JumpToNextMarker;
062import org.openstreetmap.josm.gui.layer.JumpToMarkerActions.JumpToPreviousMarker;
063import org.openstreetmap.josm.gui.layer.Layer;
064import org.openstreetmap.josm.gui.layer.MainLayerManager.ActiveLayerChangeListener;
065import org.openstreetmap.josm.gui.util.imagery.Vector3D;
066import org.openstreetmap.josm.tools.ImageProvider;
067import org.openstreetmap.josm.tools.Utils;
068
069/**
070 * Layer displaying geotagged pictures.
071 * @since 99
072 */
073public class GeoImageLayer extends AbstractModifiableLayer implements
074        JumpToMarkerLayer, NavigatableComponent.ZoomChangeListener, ImageDataUpdateListener {
075
076    private static final List<Action> menuAdditions = new LinkedList<>();
077
078    private static volatile List<MapMode> supportedMapModes;
079
080    private final ImageData data;
081    GpxData gpxData;
082    GpxLayer gpxFauxLayer;
083    GpxData gpxFauxData;
084
085    private CorrelateGpxWithImages gpxCorrelateAction;
086
087    private final Icon icon = ImageProvider.get("dialogs/geoimage/photo-marker");
088    private final Icon selectedIcon = ImageProvider.get("dialogs/geoimage/photo-marker-selected");
089
090    boolean useThumbs;
091    private final ExecutorService thumbsLoaderExecutor =
092            Executors.newSingleThreadExecutor(Utils.newThreadFactory("thumbnail-loader-%d", Thread.MIN_PRIORITY));
093    private ThumbsLoader thumbsloader;
094    private boolean thumbsLoaderRunning;
095    volatile boolean thumbsLoaded;
096    private BufferedImage offscreenBuffer;
097    private boolean updateOffscreenBuffer = true;
098
099    private MouseAdapter mouseAdapter;
100    private MouseMotionAdapter mouseMotionAdapter;
101    private MapModeChangeListener mapModeListener;
102    private ActiveLayerChangeListener activeLayerChangeListener;
103
104    /** Mouse position where the last image was selected. */
105    private Point lastSelPos;
106    /** The mouse point */
107    private Point startPoint;
108
109    /**
110     * Image cycle mode flag.
111     * It is possible that a mouse button release triggers multiple mouseReleased() events.
112     * To prevent the cycling in such a case we wait for the next mouse button press event
113     * before it is cycled to the next image.
114     */
115    private boolean cycleModeArmed;
116
117    /**
118     * Constructs a new {@code GeoImageLayer}.
119     * @param data The list of images to display
120     * @param gpxLayer The associated GPX layer
121     */
122    public GeoImageLayer(final List<ImageEntry> data, GpxLayer gpxLayer) {
123        this(data, gpxLayer, null, false);
124    }
125
126    /**
127     * Constructs a new {@code GeoImageLayer}.
128     * @param data The list of images to display
129     * @param gpxLayer The associated GPX layer
130     * @param name Layer name
131     * @since 6392
132     */
133    public GeoImageLayer(final List<ImageEntry> data, GpxLayer gpxLayer, final String name) {
134        this(data, gpxLayer, name, false);
135    }
136
137    /**
138     * Constructs a new {@code GeoImageLayer}.
139     * @param data The list of images to display
140     * @param gpxLayer The associated GPX layer
141     * @param useThumbs Thumbnail display flag
142     * @since 6392
143     */
144    public GeoImageLayer(final List<ImageEntry> data, GpxLayer gpxLayer, boolean useThumbs) {
145        this(data, gpxLayer, null, useThumbs);
146    }
147
148    /**
149     * Constructs a new {@code GeoImageLayer}.
150     * @param data The list of images to display
151     * @param gpxLayer The associated GPX layer
152     * @param name Layer name
153     * @param useThumbs Thumbnail display flag
154     * @since 6392
155     */
156    public GeoImageLayer(final List<ImageEntry> data, GpxLayer gpxLayer, final String name, boolean useThumbs) {
157        this(data, gpxLayer != null ? gpxLayer.data : null, name, useThumbs);
158    }
159
160    /**
161     * Constructs a new {@code GeoImageLayer}.
162     * @param data The list of images to display
163     * @param gpxData The associated GPX data
164     * @param name Layer name
165     * @param useThumbs Thumbnail display flag
166     * @since 18078
167     */
168    public GeoImageLayer(final List<ImageEntry> data, GpxData gpxData, final String name, boolean useThumbs) {
169        super(!Utils.isBlank(name) ? name : tr("Geotagged Images"));
170        this.data = new ImageData(data);
171        this.gpxData = gpxData;
172        this.useThumbs = useThumbs;
173        this.data.addImageDataUpdateListener(this);
174    }
175
176    private final class ImageMouseListener extends MouseAdapter {
177        private boolean isMapModeOk() {
178            MapMode mapMode = MainApplication.getMap().mapMode;
179            return mapMode == null || isSupportedMapMode(mapMode);
180        }
181
182        @Override
183        public void mousePressed(MouseEvent e) {
184            if (e.getButton() != MouseEvent.BUTTON1)
185                return;
186            if (isVisible() && isMapModeOk()) {
187                cycleModeArmed = true;
188                invalidate();
189                startPoint = e.getPoint();
190            }
191        }
192
193        @Override
194        public void mouseReleased(MouseEvent ev) {
195            if (ev.getButton() != MouseEvent.BUTTON1)
196                return;
197            if (!isVisible() || !isMapModeOk())
198                return;
199            if (!cycleModeArmed) {
200                return;
201            }
202
203            Rectangle hitBoxClick = new Rectangle((int) startPoint.getX() - 10, (int) startPoint.getY() - 10, 15, 15);
204            if (!hitBoxClick.contains(ev.getPoint())) {
205                return;
206            }
207
208            Point mousePos = ev.getPoint();
209            boolean cycle = cycleModeArmed && lastSelPos != null && lastSelPos.equals(mousePos);
210            final boolean isShift = (ev.getModifiersEx() & MouseEvent.SHIFT_DOWN_MASK) == MouseEvent.SHIFT_DOWN_MASK;
211            final boolean isCtrl = (ev.getModifiersEx() & MouseEvent.CTRL_DOWN_MASK) == MouseEvent.CTRL_DOWN_MASK;
212            int idx = getPhotoIdxUnderMouse(ev, cycle);
213            if (idx >= 0) {
214                lastSelPos = mousePos;
215                cycleModeArmed = false;
216                ImageEntry img = data.getImages().get(idx);
217                if (isShift) {
218                    if (isCtrl && !data.getSelectedImages().isEmpty()) {
219                        int idx2 = data.getImages().indexOf(data.getSelectedImages().get(data.getSelectedImages().size() - 1));
220                        int startIndex = Math.min(idx, idx2);
221                        int endIndex = Math.max(idx, idx2);
222                        for (int i = startIndex; i <= endIndex; i++) {
223                            data.addImageToSelection(data.getImages().get(i));
224                        }
225                    } else {
226                        if (data.isImageSelected(img)) {
227                            data.removeImageToSelection(img);
228                        } else {
229                            data.addImageToSelection(img);
230                        }
231                    }
232                } else {
233                    data.setSelectedImage(img);
234                }
235            }
236        }
237    }
238
239    /**
240     * Create a GeoImageLayer asynchronously
241     * @param files the list of image files to display
242     * @param gpxLayer the gpx layer
243     */
244    public static void create(Collection<File> files, GpxLayer gpxLayer) {
245        MainApplication.worker.execute(new ImagesLoader(files, gpxLayer));
246    }
247
248    @Override
249    public Icon getIcon() {
250        return ImageProvider.get("dialogs/geoimage", ImageProvider.ImageSizes.LAYER);
251    }
252
253    /**
254     * Register actions on the layer
255     * @param addition the action to be added
256     */
257    public static void registerMenuAddition(Action addition) {
258        menuAdditions.add(addition);
259    }
260
261    @Override
262    public Action[] getMenuEntries() {
263        List<Action> entries = new ArrayList<>();
264        entries.add(LayerListDialog.getInstance().createShowHideLayerAction());
265        entries.add(LayerListDialog.getInstance().createDeleteLayerAction());
266        entries.add(MainApplication.getMenu().autoScaleActions.get(AutoScaleAction.AutoScaleMode.LAYER));
267        entries.add(LayerListDialog.getInstance().createMergeLayerAction(this));
268        entries.add(new RenameLayerAction(null, this));
269        entries.add(SeparatorLayerAction.INSTANCE);
270        entries.add(getGpxCorrelateAction());
271        if (ExpertToggleAction.isExpert()) {
272            entries.add(new EditImagesSequenceAction(this));
273            entries.add(new LayerGpxExportAction(this));
274        }
275        entries.add(new ShowThumbnailAction(this));
276        if (!menuAdditions.isEmpty()) {
277            entries.add(SeparatorLayerAction.INSTANCE);
278            entries.addAll(menuAdditions);
279        }
280        entries.add(SeparatorLayerAction.INSTANCE);
281        entries.add(new JumpToNextMarker(this));
282        entries.add(new JumpToPreviousMarker(this));
283        entries.add(SeparatorLayerAction.INSTANCE);
284        entries.add(new LayerListPopup.InfoAction(this));
285
286        return entries.toArray(new Action[0]);
287    }
288
289    /**
290     * Prepare the string that is displayed if layer information is requested.
291     * @return String with layer information
292     */
293    private String infoText() {
294        int tagged = 0;
295        int newdata = 0;
296        int n = data.getImages().size();
297        for (ImageEntry e : data.getImages()) {
298            if (e.getPos() != null) {
299                tagged++;
300            }
301            if (e.hasNewGpsData()) {
302                newdata++;
303            }
304        }
305        return "<html>"
306                + trn("{0} image loaded.", "{0} images loaded.", n, n)
307                + ' ' + trn("{0} was found to be GPS tagged.", "{0} were found to be GPS tagged.", tagged, tagged)
308                + (newdata > 0 ? "<br>" + trn("{0} has updated GPS data.", "{0} have updated GPS data.", newdata, newdata) : "")
309                + "</html>";
310    }
311
312    @Override
313    public Object getInfoComponent() {
314        return infoText();
315    }
316
317    @Override
318    public String getToolTipText() {
319        return infoText();
320    }
321
322    /**
323     * Determines if data managed by this layer has been modified.  That is
324     * the case if one image has modified GPS data.
325     * @return {@code true} if data has been modified; {@code false}, otherwise
326     */
327    @Override
328    public boolean isModified() {
329        return this.data.isModified();
330    }
331
332    @Override
333    public boolean isMergable(Layer other) {
334        return other instanceof GeoImageLayer;
335    }
336
337    @Override
338    public void mergeFrom(Layer from) {
339        if (!(from instanceof GeoImageLayer))
340            throw new IllegalArgumentException("not a GeoImageLayer: " + from);
341        GeoImageLayer l = (GeoImageLayer) from;
342
343        // Stop to load thumbnails on both layers.  Thumbnail loading will continue the next time
344        // the layer is painted.
345        stopLoadThumbs();
346        l.stopLoadThumbs();
347
348        this.data.mergeFrom(l.getImageData());
349
350        setName(l.getName());
351        thumbsLoaded &= l.thumbsLoaded;
352    }
353
354    private static Dimension scaledDimension(Image thumb) {
355        final double d = MainApplication.getMap().mapView.getDist100Pixel();
356        final double size = 10 /*meter*/;     /* size of the photo on the map */
357        double s = size * 100 /*px*/ / d;
358
359        final double sMin = ThumbsLoader.minSize;
360        final double sMax = ThumbsLoader.maxSize;
361
362        if (s < sMin) {
363            s = sMin;
364        }
365        if (s > sMax) {
366            s = sMax;
367        }
368        final double f = s / sMax;  /* scale factor */
369
370        if (thumb == null)
371            return null;
372
373        return new Dimension(
374                (int) Math.round(f * thumb.getWidth(null)),
375                (int) Math.round(f * thumb.getHeight(null)));
376    }
377
378    /**
379     * Paint one image.
380     * @param e Image to be painted
381     * @param mv Map view
382     * @param clip Bounding rectangle of the current clipping area
383     * @param tempG Temporary offscreen buffer
384     */
385    private void paintImage(ImageEntry e, MapView mv, Rectangle clip, Graphics2D tempG) {
386        if (e.getPos() == null) {
387            return;
388        }
389        Point p = mv.getPoint(e.getPos());
390        if (e.hasThumbnail()) {
391            Dimension d = scaledDimension(e.getThumbnail());
392            if (d != null) {
393                Rectangle target = new Rectangle(p.x - d.width / 2, p.y - d.height / 2, d.width, d.height);
394                if (clip.intersects(target)) {
395                    tempG.drawImage(e.getThumbnail(), target.x, target.y, target.width, target.height, null);
396                }
397            }
398        } else { // thumbnail not loaded yet
399            icon.paintIcon(mv, tempG,
400                p.x - icon.getIconWidth() / 2,
401                p.y - icon.getIconHeight() / 2);
402        }
403    }
404
405    @Override
406    public void paint(Graphics2D g, MapView mv, Bounds bounds) {
407        int width = mv.getWidth();
408        int height = mv.getHeight();
409        Rectangle clip = g.getClipBounds();
410        if (useThumbs) {
411            if (!thumbsLoaded) {
412                startLoadThumbs();
413            }
414
415            if (null == offscreenBuffer
416                    || offscreenBuffer.getWidth() != width  // reuse the old buffer if possible
417                    || offscreenBuffer.getHeight() != height) {
418                offscreenBuffer = new BufferedImage(width, height, BufferedImage.TYPE_INT_ARGB);
419                updateOffscreenBuffer = true;
420            }
421
422            if (updateOffscreenBuffer) {
423                Graphics2D tempG = offscreenBuffer.createGraphics();
424                tempG.setColor(new Color(0, 0, 0, 0));
425                Composite saveComp = tempG.getComposite();
426                tempG.setComposite(AlphaComposite.Clear);   // remove the old images
427                tempG.fillRect(0, 0, width, height);
428                tempG.setComposite(saveComp);
429
430                for (ImageEntry e : data.searchImages(bounds)) {
431                    paintImage(e, mv, clip, tempG);
432                }
433                for (ImageEntry img: this.data.getSelectedImages()) {
434                    // Make sure the selected image is on top in case multiple images overlap.
435                    paintImage(img, mv, clip, tempG);
436                }
437                updateOffscreenBuffer = false;
438            }
439            g.drawImage(offscreenBuffer, 0, 0, null);
440        } else {
441            for (ImageEntry e : data.searchImages(bounds)) {
442                if (e.getPos() == null) {
443                    continue;
444                }
445                Point p = mv.getPoint(e.getPos());
446                icon.paintIcon(mv, g,
447                        p.x - icon.getIconWidth() / 2,
448                        p.y - icon.getIconHeight() / 2);
449            }
450        }
451
452        for (ImageEntry e: data.getSelectedImages()) {
453            if (e != null && e.getPos() != null) {
454                Point p = mv.getPoint(e.getPos());
455                Dimension imgDim = getImageDimension(e);
456
457                if (e.getExifImgDir() != null) {
458                    Vector3D imgRotation = ImageViewerDialog.getInstance().getRotation(e);
459                    drawDirectionArrow(g, p, e.getExifImgDir()
460                            + (imgRotation != null ? Utils.toDegrees(imgRotation.getPolarAngle()) : 0d), imgDim);
461                }
462
463                if (useThumbs && e.hasThumbnail()) {
464                    g.setColor(new Color(128, 0, 0, 122));
465                    g.fillRect(p.x - imgDim.width / 2, p.y - imgDim.height / 2, imgDim.width, imgDim.height);
466                } else {
467                    selectedIcon.paintIcon(mv, g,
468                            p.x - imgDim.width / 2,
469                            p.y - imgDim.height / 2);
470                }
471            }
472        }
473    }
474
475    protected Dimension getImageDimension(ImageEntry e) {
476        if (useThumbs && e.hasThumbnail()) {
477            Dimension d = scaledDimension(e.getThumbnail());
478            return d != null ? d : new Dimension(-1, -1);
479        } else {
480            return new Dimension(selectedIcon.getIconWidth(), selectedIcon.getIconHeight());
481        }
482    }
483
484    protected static void drawDirectionArrow(Graphics2D g, Point p, double dir, Dimension imgDim) {
485        // Multiplier must be larger than sqrt(2)/2=0.71.
486        double arrowlength = Math.max(25, Math.max(imgDim.width, imgDim.height) * 0.85);
487        double arrowwidth = arrowlength / 1.4;
488
489        // Rotate 90 degrees CCW
490        double headdir = (dir < 90) ? dir + 270 : dir - 90;
491        double leftdir = (headdir < 90) ? headdir + 270 : headdir - 90;
492        double rightdir = (headdir > 270) ? headdir - 270 : headdir + 90;
493
494        double ptx = p.x + Math.cos(Utils.toRadians(headdir)) * arrowlength;
495        double pty = p.y + Math.sin(Utils.toRadians(headdir)) * arrowlength;
496
497        double ltx = p.x + Math.cos(Utils.toRadians(leftdir)) * arrowwidth/2;
498        double lty = p.y + Math.sin(Utils.toRadians(leftdir)) * arrowwidth/2;
499
500        double rtx = p.x + Math.cos(Utils.toRadians(rightdir)) * arrowwidth/2;
501        double rty = p.y + Math.sin(Utils.toRadians(rightdir)) * arrowwidth/2;
502
503        g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
504        g.setColor(new Color(255, 255, 255, 192));
505        int[] xar = {(int) ltx, (int) ptx, (int) rtx, (int) ltx};
506        int[] yar = {(int) lty, (int) pty, (int) rty, (int) lty};
507        g.fillPolygon(xar, yar, 4);
508        g.setColor(Color.black);
509        g.setStroke(new BasicStroke(1.2f));
510        g.drawPolyline(xar, yar, 3);
511    }
512
513    @Override
514    public void visitBoundingBox(BoundingXYVisitor v) {
515        for (ImageEntry e : data.getImages()) {
516            v.visit(e.getPos());
517        }
518    }
519
520    /**
521     * Show current photo on map and in image viewer.
522     */
523    public void showCurrentPhoto() {
524        if (data.getSelectedImage() != null) {
525            clearOtherCurrentPhotos();
526        }
527        updateBufferAndRepaint();
528    }
529
530    /**
531     * Check if the position of the mouse event is within the rectangle of the photo icon or thumbnail.
532     * @param idx the image index
533     * @param evt Mouse event
534     * @return {@code true} if the photo matches the mouse position, {@code false} otherwise
535     */
536    private boolean isPhotoIdxUnderMouse(int idx, MouseEvent evt) {
537        ImageEntry img = data.getImages().get(idx);
538        if (img.getPos() != null) {
539            Point imgCenter = MainApplication.getMap().mapView.getPoint(img.getPos());
540            Rectangle imgRect;
541            if (useThumbs && img.hasThumbnail()) {
542                Dimension imgDim = scaledDimension(img.getThumbnail());
543                if (imgDim != null) {
544                    imgRect = new Rectangle(imgCenter.x - imgDim.width / 2,
545                                            imgCenter.y - imgDim.height / 2,
546                                            imgDim.width, imgDim.height);
547                } else {
548                    imgRect = null;
549                }
550            } else {
551                imgRect = new Rectangle(imgCenter.x - icon.getIconWidth() / 2,
552                                        imgCenter.y - icon.getIconHeight() / 2,
553                                        icon.getIconWidth(), icon.getIconHeight());
554            }
555            if (imgRect != null && imgRect.contains(evt.getPoint())) {
556                return true;
557            }
558        }
559        return false;
560    }
561
562    /**
563     * Returns index of the image that matches the position of the mouse event.
564     * @param evt    Mouse event
565     * @param cycle  Set to {@code true} to cycle through the photos at the
566     *               current mouse position if multiple icons or thumbnails overlap.
567     *               If set to {@code false} the topmost photo will be used.
568     * @return       Image index at mouse position, range 0 .. size-1,
569     *               or {@code -1} if there is no image at the mouse position
570     */
571    private int getPhotoIdxUnderMouse(MouseEvent evt, boolean cycle) {
572        ImageEntry selectedImage = data.getSelectedImage();
573        int selectedIndex = data.getImages().indexOf(selectedImage);
574
575        if (cycle && selectedImage != null) {
576            // Cycle loop is forward as that is the natural order.
577            // Loop 1: One after current photo up to last one.
578            for (int idx = selectedIndex + 1; idx < data.getImages().size(); ++idx) {
579                if (isPhotoIdxUnderMouse(idx, evt)) {
580                    return idx;
581                }
582            }
583            // Loop 2: First photo up to current one.
584            for (int idx = 0; idx <= selectedIndex; ++idx) {
585                if (isPhotoIdxUnderMouse(idx, evt)) {
586                    return idx;
587                }
588            }
589        } else {
590            // Check for current photo first, i.e. keep it selected if it is under the mouse.
591            if (selectedImage != null && isPhotoIdxUnderMouse(selectedIndex, evt)) {
592                return selectedIndex;
593            }
594            // Loop from last to first to prefer topmost image.
595            for (int idx = data.getImages().size() - 1; idx >= 0; --idx) {
596                if (isPhotoIdxUnderMouse(idx, evt)) {
597                    return idx;
598                }
599            }
600        }
601        return -1;
602    }
603
604    /**
605     * Returns index of the image that matches the position of the mouse event.
606     * The topmost photo is picked if multiple icons or thumbnails overlap.
607     * @param evt Mouse event
608     * @return Image index at mouse position, range 0 .. size-1,
609     *         or {@code -1} if there is no image at the mouse position
610     */
611    private int getPhotoIdxUnderMouse(MouseEvent evt) {
612        return getPhotoIdxUnderMouse(evt, false);
613    }
614
615    /**
616     * Returns the image that matches the position of the mouse event.
617     * The topmost photo is picked of multiple icons or thumbnails overlap.
618     * @param evt Mouse event
619     * @return Image at mouse position, or {@code null} if there is no image at the mouse position
620     * @since 6392
621     */
622    public ImageEntry getPhotoUnderMouse(MouseEvent evt) {
623        int idx = getPhotoIdxUnderMouse(evt);
624        if (idx >= 0) {
625            return data.getImages().get(idx);
626        } else {
627            return null;
628        }
629    }
630
631    /**
632     * Clears the currentPhoto of the other GeoImageLayer's. Otherwise there could be multiple selected photos.
633     */
634    private void clearOtherCurrentPhotos() {
635        for (GeoImageLayer layer:
636                 MainApplication.getLayerManager().getLayersOfType(GeoImageLayer.class)) {
637            if (layer != this) {
638                layer.getImageData().clearSelectedImage();
639            }
640        }
641    }
642
643    /**
644     * Registers a map mode for which the functionality of this layer should be available.
645     * @param mapMode Map mode to be registered
646     * @since 6392
647     */
648    public static void registerSupportedMapMode(MapMode mapMode) {
649        if (supportedMapModes == null) {
650            supportedMapModes = new ArrayList<>();
651        }
652        supportedMapModes.add(mapMode);
653    }
654
655    /**
656     * Determines if the functionality of this layer is available in
657     * the specified map mode. {@link SelectAction} and {@link SelectLassoAction} are supported by default,
658     * other map modes can be registered.
659     * @param mapMode Map mode to be checked
660     * @return {@code true} if the map mode is supported,
661     *         {@code false} otherwise
662     */
663    private static boolean isSupportedMapMode(MapMode mapMode) {
664        if (mapMode instanceof SelectAction || mapMode instanceof SelectLassoAction) {
665            return true;
666        }
667        return supportedMapModes != null && supportedMapModes.stream().anyMatch(supmmode -> mapMode == supmmode);
668    }
669
670    @Override
671    public void hookUpMapView() {
672        mouseAdapter = new ImageMouseListener();
673
674        mouseMotionAdapter = new MouseMotionAdapter() {
675            @Override
676            public void mouseMoved(MouseEvent evt) {
677                lastSelPos = null;
678            }
679
680            @Override
681            public void mouseDragged(MouseEvent evt) {
682                lastSelPos = null;
683            }
684        };
685
686        mapModeListener = (oldMapMode, newMapMode) -> {
687            MapView mapView = MainApplication.getMap().mapView;
688            if (newMapMode == null || isSupportedMapMode(newMapMode)) {
689                mapView.addMouseListener(mouseAdapter);
690                mapView.addMouseMotionListener(mouseMotionAdapter);
691            } else {
692                mapView.removeMouseListener(mouseAdapter);
693                mapView.removeMouseMotionListener(mouseMotionAdapter);
694            }
695        };
696
697        MapFrame.addMapModeChangeListener(mapModeListener);
698        mapModeListener.mapModeChange(null, MainApplication.getMap().mapMode);
699
700        activeLayerChangeListener = e -> {
701            if (MainApplication.getLayerManager().getActiveLayer() == this) {
702                // only in select mode it is possible to click the images
703                MainApplication.getMap().selectSelectTool(false);
704            }
705        };
706        MainApplication.getLayerManager().addActiveLayerChangeListener(activeLayerChangeListener);
707
708        MapFrame map = MainApplication.getMap();
709        if (map.getToggleDialog(ImageViewerDialog.class) == null) {
710            ImageViewerDialog.createInstance();
711            map.addToggleDialog(ImageViewerDialog.getInstance());
712        }
713    }
714
715    @Override
716    public synchronized void destroy() {
717        super.destroy();
718        stopLoadThumbs();
719        if (gpxCorrelateAction != null) {
720            gpxCorrelateAction.destroy();
721            gpxCorrelateAction = null;
722        }
723        MapView mapView = MainApplication.getMap().mapView;
724        mapView.removeMouseListener(mouseAdapter);
725        mapView.removeMouseMotionListener(mouseMotionAdapter);
726        MapView.removeZoomChangeListener(this);
727        MapFrame.removeMapModeChangeListener(mapModeListener);
728        MainApplication.getLayerManager().removeActiveLayerChangeListener(activeLayerChangeListener);
729        data.removeImageDataUpdateListener(this);
730    }
731
732    @Override
733    public LayerPainter attachToMapView(MapViewEvent event) {
734        MapView.addZoomChangeListener(this);
735        return new CompatibilityModeLayerPainter() {
736            @Override
737            public void detachFromMapView(MapViewEvent event) {
738                MapView.removeZoomChangeListener(GeoImageLayer.this);
739            }
740        };
741    }
742
743    @Override
744    public void zoomChanged() {
745        updateBufferAndRepaint();
746    }
747
748    /**
749     * Start to load thumbnails.
750     */
751    public synchronized void startLoadThumbs() {
752        if (useThumbs && !thumbsLoaded && !thumbsLoaderRunning) {
753            stopLoadThumbs();
754            thumbsloader = new ThumbsLoader(this);
755            thumbsLoaderExecutor.submit(thumbsloader);
756            thumbsLoaderRunning = true;
757        }
758    }
759
760    /**
761     * Stop to load thumbnails.
762     *
763     * Can be called at any time to make sure that the
764     * thumbnail loader is stopped.
765     */
766    public synchronized void stopLoadThumbs() {
767        if (thumbsloader != null) {
768            thumbsloader.stop = true;
769        }
770        thumbsLoaderRunning = false;
771    }
772
773    /**
774     * Called to signal that the loading of thumbnails has finished.
775     *
776     * Usually called from {@link ThumbsLoader} in another thread.
777     */
778    public void thumbsLoaded() {
779        thumbsLoaded = true;
780    }
781
782    /**
783     * Marks the offscreen buffer to be updated.
784     */
785    public void updateBufferAndRepaint() {
786        updateOffscreenBuffer = true;
787        invalidate();
788    }
789
790    /**
791     * Get list of images in layer.
792     * @return List of images in layer
793     */
794    public List<ImageEntry> getImages() {
795        return new ArrayList<>(data.getImages());
796    }
797
798    /**
799     * Returns the image data store being used by this layer
800     * @return imageData
801     * @since 14590
802     */
803    public ImageData getImageData() {
804        return data;
805    }
806
807    /**
808     * Returns the associated GPX data if any.
809     * @return The associated GPX data or {@code null}
810     * @since 18078
811     */
812    public GpxData getGpxData() {
813        return gpxData;
814    }
815
816    /**
817     * Returns the associated GPX layer if any.
818     * @return The associated GPX layer or {@code null}
819     */
820    public GpxLayer getGpxLayer() {
821        return gpxData != null ? MainApplication.getLayerManager().getLayersOfType(GpxLayer.class)
822                .stream().filter(l -> gpxData.equals(l.getGpxData()))
823                .findFirst().orElseThrow(() -> new IllegalStateException()) : null;
824    }
825
826    /**
827     * Returns the gpxCorrelateAction
828     * @return the gpxCorrelateAction
829     */
830    public CorrelateGpxWithImages getGpxCorrelateAction() {
831        if (gpxCorrelateAction == null) {
832            gpxCorrelateAction = new CorrelateGpxWithImages(this);
833        }
834        return gpxCorrelateAction;
835    }
836
837    /**
838     * Returns a faux GPX layer built from the images or the associated GPX layer.
839     * @return A faux GPX layer or the associated GPX layer
840     * @since 14802
841     */
842    public synchronized GpxLayer getFauxGpxLayer() {
843        GpxLayer gpxLayer = getGpxLayer();
844        if (gpxLayer != null) return gpxLayer;
845        if (gpxFauxLayer == null) {
846            gpxFauxLayer = new GpxLayer(getFauxGpxData());
847        }
848        return gpxFauxLayer;
849    }
850
851    /**
852     * Returns a faux GPX data built from the images or the associated GPX layer data.
853     * @return A faux GPX data or the associated GPX layer data
854     * @since 18065
855     */
856    public synchronized GpxData getFauxGpxData() {
857        GpxLayer gpxLayer = getGpxLayer();
858        if (gpxLayer != null) return gpxLayer.data;
859        if (gpxFauxData == null) {
860            gpxFauxData = new GpxData();
861            gpxFauxData.addTrack(new GpxTrack(Arrays.asList(
862                    data.getImages().stream().map(ImageEntry::asWayPoint).filter(Objects::nonNull).collect(toList())),
863                    Collections.emptyMap()));
864        }
865        return gpxFauxData;
866    }
867
868    @Override
869    public void jumpToNextMarker() {
870        data.setSelectedImage(data.getNextImage());
871    }
872
873    @Override
874    public void jumpToPreviousMarker() {
875        data.setSelectedImage(data.getPreviousImage());
876    }
877
878    /**
879     * Returns the current thumbnail display status.
880     * {@code true}: thumbnails are displayed, {@code false}: an icon is displayed instead of thumbnails.
881     * @return Current thumbnail display status
882     * @since 6392
883     */
884    public boolean isUseThumbs() {
885        return useThumbs;
886    }
887
888    /**
889     * Enables or disables the display of thumbnails.  Does not update the display.
890     * @param useThumbs New thumbnail display status
891     * @since 6392
892     */
893    public void setUseThumbs(boolean useThumbs) {
894        this.useThumbs = useThumbs;
895        if (useThumbs && !thumbsLoaded) {
896            startLoadThumbs();
897        } else if (!useThumbs) {
898            stopLoadThumbs();
899        }
900        invalidate();
901    }
902
903    @Override
904    public void selectedImageChanged(ImageData data) {
905        showCurrentPhoto();
906    }
907
908    @Override
909    public void imageDataUpdated(ImageData data) {
910        updateBufferAndRepaint();
911    }
912
913    @Override
914    public String getChangesetSourceTag() {
915        return "Geotagged Images";
916    }
917
918    @Override
919    public Data getData() {
920        return data;
921    }
922
923    void applyTmp() {
924        data.getImages().forEach(ImageEntry::applyTmp);
925    }
926
927    void discardTmp() {
928        data.getImages().forEach(ImageEntry::discardTmp);
929    }
930
931    /**
932     * Returns a list of images that fulfill the given criteria.
933     * Default setting is to return untagged images, but may be overwritten.
934     * @param exif also returns images with exif-gps info
935     * @param tagged also returns tagged images
936     * @return matching images
937     */
938    List<ImageEntry> getSortedImgList(boolean exif, boolean tagged) {
939        return data.getImages().stream()
940                .filter(GpxImageEntry::hasExifTime)
941                .filter(e -> e.getExifCoor() == null || exif)
942                .filter(e -> tagged || !e.isTagged() || e.getExifCoor() != null)
943                .sorted(Comparator.comparing(ImageEntry::getExifInstant))
944                .collect(toList());
945    }
946}