source: josm/trunk/src/org/openstreetmap/josm/gui/layer/geoimage/GeoImageLayer.java@ 16438

Last change on this file since 16438 was 16438, checked in by simon04, 4 years ago

see #19251 - Java 8: use Stream

  • Property svn:eol-style set to native
File size: 36.0 KB
Line 
1// License: GPL. For details, see LICENSE file.
2package org.openstreetmap.josm.gui.layer.geoimage;
3
4import static org.openstreetmap.josm.tools.I18n.tr;
5import static org.openstreetmap.josm.tools.I18n.trn;
6
7import java.awt.AlphaComposite;
8import java.awt.BasicStroke;
9import java.awt.Color;
10import java.awt.Composite;
11import java.awt.Dimension;
12import java.awt.Graphics2D;
13import java.awt.Image;
14import java.awt.Point;
15import java.awt.Rectangle;
16import java.awt.RenderingHints;
17import java.awt.event.MouseAdapter;
18import java.awt.event.MouseEvent;
19import java.awt.event.MouseMotionAdapter;
20import java.awt.image.BufferedImage;
21import java.io.File;
22import java.io.IOException;
23import java.util.ArrayList;
24import java.util.Arrays;
25import java.util.Collection;
26import java.util.HashSet;
27import java.util.LinkedHashSet;
28import java.util.LinkedList;
29import java.util.List;
30import java.util.Set;
31import java.util.concurrent.ExecutorService;
32import java.util.concurrent.Executors;
33
34import javax.swing.Action;
35import javax.swing.Icon;
36import javax.swing.JOptionPane;
37
38import org.openstreetmap.josm.actions.RenameLayerAction;
39import org.openstreetmap.josm.actions.mapmode.SelectLassoAction;
40import org.openstreetmap.josm.actions.mapmode.MapMode;
41import org.openstreetmap.josm.actions.mapmode.SelectAction;
42import org.openstreetmap.josm.data.Bounds;
43import org.openstreetmap.josm.data.ImageData;
44import org.openstreetmap.josm.data.ImageData.ImageDataUpdateListener;
45import org.openstreetmap.josm.data.gpx.GpxData;
46import org.openstreetmap.josm.data.gpx.WayPoint;
47import org.openstreetmap.josm.data.osm.visitor.BoundingXYVisitor;
48import org.openstreetmap.josm.gui.MainApplication;
49import org.openstreetmap.josm.gui.MapFrame;
50import org.openstreetmap.josm.gui.MapFrame.MapModeChangeListener;
51import org.openstreetmap.josm.gui.MapView;
52import org.openstreetmap.josm.gui.NavigatableComponent;
53import org.openstreetmap.josm.gui.PleaseWaitRunnable;
54import org.openstreetmap.josm.gui.dialogs.LayerListDialog;
55import org.openstreetmap.josm.gui.dialogs.LayerListPopup;
56import org.openstreetmap.josm.gui.io.importexport.JpgImporter;
57import org.openstreetmap.josm.gui.layer.AbstractModifiableLayer;
58import org.openstreetmap.josm.gui.layer.GpxLayer;
59import org.openstreetmap.josm.gui.layer.JumpToMarkerActions.JumpToMarkerLayer;
60import org.openstreetmap.josm.gui.layer.JumpToMarkerActions.JumpToNextMarker;
61import org.openstreetmap.josm.gui.layer.JumpToMarkerActions.JumpToPreviousMarker;
62import org.openstreetmap.josm.gui.layer.Layer;
63import org.openstreetmap.josm.gui.layer.MainLayerManager.ActiveLayerChangeListener;
64import org.openstreetmap.josm.tools.ImageProvider;
65import org.openstreetmap.josm.tools.Logging;
66import org.openstreetmap.josm.tools.Utils;
67
68/**
69 * Layer displaying geottaged pictures.
70 */
71public class GeoImageLayer extends AbstractModifiableLayer implements
72 JumpToMarkerLayer, NavigatableComponent.ZoomChangeListener, ImageDataUpdateListener {
73
74 private static List<Action> menuAdditions = new LinkedList<>();
75
76 private static volatile List<MapMode> supportedMapModes;
77
78 private final ImageData data;
79 GpxLayer gpxLayer;
80 GpxLayer gpxFauxLayer;
81
82 private final Icon icon = ImageProvider.get("dialogs/geoimage/photo-marker");
83 private final Icon selectedIcon = ImageProvider.get("dialogs/geoimage/photo-marker-selected");
84
85 boolean useThumbs;
86 private final ExecutorService thumbsLoaderExecutor =
87 Executors.newSingleThreadExecutor(Utils.newThreadFactory("thumbnail-loader-%d", Thread.MIN_PRIORITY));
88 private ThumbsLoader thumbsloader;
89 private boolean thumbsLoaderRunning;
90 volatile boolean thumbsLoaded;
91 private BufferedImage offscreenBuffer;
92 private boolean updateOffscreenBuffer = true;
93
94 private MouseAdapter mouseAdapter;
95 private MouseMotionAdapter mouseMotionAdapter;
96 private MapModeChangeListener mapModeListener;
97 private ActiveLayerChangeListener activeLayerChangeListener;
98
99 /** Mouse position where the last image was selected. */
100 private Point lastSelPos;
101 /** The mouse point */
102 private Point startPoint;
103
104 /**
105 * Image cycle mode flag.
106 * It is possible that a mouse button release triggers multiple mouseReleased() events.
107 * To prevent the cycling in such a case we wait for the next mouse button press event
108 * before it is cycled to the next image.
109 */
110 private boolean cycleModeArmed;
111
112 /**
113 * Constructs a new {@code GeoImageLayer}.
114 * @param data The list of images to display
115 * @param gpxLayer The associated GPX layer
116 */
117 public GeoImageLayer(final List<ImageEntry> data, GpxLayer gpxLayer) {
118 this(data, gpxLayer, null, false);
119 }
120
121 /**
122 * Constructs a new {@code GeoImageLayer}.
123 * @param data The list of images to display
124 * @param gpxLayer The associated GPX layer
125 * @param name Layer name
126 * @since 6392
127 */
128 public GeoImageLayer(final List<ImageEntry> data, GpxLayer gpxLayer, final String name) {
129 this(data, gpxLayer, name, false);
130 }
131
132 /**
133 * Constructs a new {@code GeoImageLayer}.
134 * @param data The list of images to display
135 * @param gpxLayer The associated GPX layer
136 * @param useThumbs Thumbnail display flag
137 * @since 6392
138 */
139 public GeoImageLayer(final List<ImageEntry> data, GpxLayer gpxLayer, boolean useThumbs) {
140 this(data, gpxLayer, null, useThumbs);
141 }
142
143 /**
144 * Constructs a new {@code GeoImageLayer}.
145 * @param data The list of images to display
146 * @param gpxLayer The associated GPX layer
147 * @param name Layer name
148 * @param useThumbs Thumbnail display flag
149 * @since 6392
150 */
151 public GeoImageLayer(final List<ImageEntry> data, GpxLayer gpxLayer, final String name, boolean useThumbs) {
152 super(name != null ? name : tr("Geotagged Images"));
153 this.data = new ImageData(data);
154 this.gpxLayer = gpxLayer;
155 this.useThumbs = useThumbs;
156 this.data.addImageDataUpdateListener(this);
157 }
158
159 private final class ImageMouseListener extends MouseAdapter {
160 private boolean isMapModeOk() {
161 MapMode mapMode = MainApplication.getMap().mapMode;
162 return mapMode == null || isSupportedMapMode(mapMode);
163 }
164
165 @Override
166 public void mousePressed(MouseEvent e) {
167 if (e.getButton() != MouseEvent.BUTTON1)
168 return;
169 if (isVisible() && isMapModeOk()) {
170 cycleModeArmed = true;
171 invalidate();
172 startPoint = e.getPoint();
173 }
174 }
175
176 @Override
177 public void mouseReleased(MouseEvent ev) {
178 if (ev.getButton() != MouseEvent.BUTTON1)
179 return;
180 if (!isVisible() || !isMapModeOk())
181 return;
182 if (!cycleModeArmed) {
183 return;
184 }
185
186 Rectangle hitBoxClick = new Rectangle((int) startPoint.getX() - 10, (int) startPoint.getY() - 10, 15, 15);
187 if (!hitBoxClick.contains(ev.getPoint())) {
188 return;
189 }
190
191 Point mousePos = ev.getPoint();
192 boolean cycle = cycleModeArmed && lastSelPos != null && lastSelPos.equals(mousePos);
193 final boolean isShift = (ev.getModifiersEx() & MouseEvent.SHIFT_DOWN_MASK) == MouseEvent.SHIFT_DOWN_MASK;
194 final boolean isCtrl = (ev.getModifiersEx() & MouseEvent.CTRL_DOWN_MASK) == MouseEvent.CTRL_DOWN_MASK;
195 int idx = getPhotoIdxUnderMouse(ev, cycle);
196 if (idx >= 0) {
197 lastSelPos = mousePos;
198 cycleModeArmed = false;
199 ImageEntry img = data.getImages().get(idx);
200 if (isShift) {
201 if (isCtrl && !data.getSelectedImages().isEmpty()) {
202 int idx2 = data.getImages().indexOf(data.getSelectedImages().get(data.getSelectedImages().size() - 1));
203 int startIndex = Math.min(idx, idx2);
204 int endIndex = Math.max(idx, idx2);
205 for (int i = startIndex; i <= endIndex; i++) {
206 data.addImageToSelection(data.getImages().get(i));
207 }
208 } else {
209 if (data.isImageSelected(img)) {
210 data.removeImageToSelection(img);
211 } else {
212 data.addImageToSelection(img);
213 }
214 }
215 } else {
216 data.setSelectedImage(img);
217 }
218 }
219 }
220 }
221
222 /**
223 * Loads a set of images, while displaying a dialog that indicates what the plugin is currently doing.
224 * In facts, this object is instantiated with a list of files. These files may be JPEG files or
225 * directories. In case of directories, they are scanned to find all the images they contain.
226 * Then all the images that have be found are loaded as ImageEntry instances.
227 */
228 static final class Loader extends PleaseWaitRunnable {
229
230 private boolean canceled;
231 private GeoImageLayer layer;
232 private final Collection<File> selection;
233 private final Set<String> loadedDirectories = new HashSet<>();
234 private final Set<String> errorMessages;
235 private final GpxLayer gpxLayer;
236
237 Loader(Collection<File> selection, GpxLayer gpxLayer) {
238 super(tr("Extracting GPS locations from EXIF"));
239 this.selection = selection;
240 this.gpxLayer = gpxLayer;
241 errorMessages = new LinkedHashSet<>();
242 }
243
244 private void rememberError(String message) {
245 this.errorMessages.add(message);
246 }
247
248 @Override
249 protected void realRun() throws IOException {
250
251 progressMonitor.subTask(tr("Starting directory scan"));
252 Collection<File> files = new ArrayList<>();
253 try {
254 addRecursiveFiles(files, selection);
255 } catch (IllegalStateException e) {
256 Logging.debug(e);
257 rememberError(e.getMessage());
258 }
259
260 if (canceled)
261 return;
262 progressMonitor.subTask(tr("Read photos..."));
263 progressMonitor.setTicksCount(files.size());
264
265 // read the image files
266 List<ImageEntry> entries = new ArrayList<>(files.size());
267
268 for (File f : files) {
269
270 if (canceled) {
271 break;
272 }
273
274 progressMonitor.subTask(tr("Reading {0}...", f.getName()));
275 progressMonitor.worked(1);
276
277 ImageEntry e = new ImageEntry(f);
278 e.extractExif();
279 entries.add(e);
280 }
281 layer = new GeoImageLayer(entries, gpxLayer);
282 files.clear();
283 }
284
285 private void addRecursiveFiles(Collection<File> files, Collection<File> sel) {
286 boolean nullFile = false;
287
288 for (File f : sel) {
289
290 if (canceled) {
291 break;
292 }
293
294 if (f == null) {
295 nullFile = true;
296
297 } else if (f.isDirectory()) {
298 String canonical = null;
299 try {
300 canonical = f.getCanonicalPath();
301 } catch (IOException e) {
302 Logging.error(e);
303 rememberError(tr("Unable to get canonical path for directory {0}\n",
304 f.getAbsolutePath()));
305 }
306
307 if (canonical == null || loadedDirectories.contains(canonical)) {
308 continue;
309 } else {
310 loadedDirectories.add(canonical);
311 }
312
313 File[] children = f.listFiles(JpgImporter.FILE_FILTER_WITH_FOLDERS);
314 if (children != null) {
315 progressMonitor.subTask(tr("Scanning directory {0}", f.getPath()));
316 addRecursiveFiles(files, Arrays.asList(children));
317 } else {
318 rememberError(tr("Error while getting files from directory {0}\n", f.getPath()));
319 }
320
321 } else {
322 files.add(f);
323 }
324 }
325
326 if (nullFile) {
327 throw new IllegalStateException(tr("One of the selected files was null"));
328 }
329 }
330
331 private String formatErrorMessages() {
332 StringBuilder sb = new StringBuilder();
333 sb.append("<html>");
334 if (errorMessages.size() == 1) {
335 sb.append(Utils.escapeReservedCharactersHTML(errorMessages.iterator().next()));
336 } else {
337 sb.append(Utils.joinAsHtmlUnorderedList(errorMessages));
338 }
339 sb.append("</html>");
340 return sb.toString();
341 }
342
343 @Override protected void finish() {
344 if (!errorMessages.isEmpty()) {
345 JOptionPane.showMessageDialog(
346 MainApplication.getMainFrame(),
347 formatErrorMessages(),
348 tr("Error"),
349 JOptionPane.ERROR_MESSAGE
350 );
351 }
352 if (layer != null) {
353 MainApplication.getLayerManager().addLayer(layer);
354
355 if (!canceled && !layer.getImageData().getImages().isEmpty()) {
356 boolean noGeotagFound = true;
357 for (ImageEntry e : layer.getImageData().getImages()) {
358 if (e.getPos() != null) {
359 noGeotagFound = false;
360 }
361 }
362 if (noGeotagFound) {
363 new CorrelateGpxWithImages(layer).actionPerformed(null);
364 }
365 }
366 }
367 }
368
369 @Override protected void cancel() {
370 canceled = true;
371 }
372 }
373
374 /**
375 * Create a GeoImageLayer asynchronously
376 * @param files the list of image files to display
377 * @param gpxLayer the gpx layer
378 */
379 public static void create(Collection<File> files, GpxLayer gpxLayer) {
380 MainApplication.worker.execute(new Loader(files, gpxLayer));
381 }
382
383 @Override
384 public Icon getIcon() {
385 return ImageProvider.get("dialogs/geoimage", ImageProvider.ImageSizes.LAYER);
386 }
387
388 /**
389 * Register actions on the layer
390 * @param addition the action to be added
391 */
392 public static void registerMenuAddition(Action addition) {
393 menuAdditions.add(addition);
394 }
395
396 @Override
397 public Action[] getMenuEntries() {
398
399 List<Action> entries = new ArrayList<>();
400 entries.add(LayerListDialog.getInstance().createShowHideLayerAction());
401 entries.add(LayerListDialog.getInstance().createDeleteLayerAction());
402 entries.add(LayerListDialog.getInstance().createMergeLayerAction(this));
403 entries.add(new RenameLayerAction(null, this));
404 entries.add(SeparatorLayerAction.INSTANCE);
405 entries.add(new CorrelateGpxWithImages(this));
406 entries.add(new ShowThumbnailAction(this));
407 if (!menuAdditions.isEmpty()) {
408 entries.add(SeparatorLayerAction.INSTANCE);
409 entries.addAll(menuAdditions);
410 }
411 entries.add(SeparatorLayerAction.INSTANCE);
412 entries.add(new JumpToNextMarker(this));
413 entries.add(new JumpToPreviousMarker(this));
414 entries.add(SeparatorLayerAction.INSTANCE);
415 entries.add(new LayerListPopup.InfoAction(this));
416
417 return entries.toArray(new Action[0]);
418
419 }
420
421 /**
422 * Prepare the string that is displayed if layer information is requested.
423 * @return String with layer information
424 */
425 private String infoText() {
426 int tagged = 0;
427 int newdata = 0;
428 int n = data.getImages().size();
429 for (ImageEntry e : data.getImages()) {
430 if (e.getPos() != null) {
431 tagged++;
432 }
433 if (e.hasNewGpsData()) {
434 newdata++;
435 }
436 }
437 return "<html>"
438 + trn("{0} image loaded.", "{0} images loaded.", n, n)
439 + ' ' + trn("{0} was found to be GPS tagged.", "{0} were found to be GPS tagged.", tagged, tagged)
440 + (newdata > 0 ? "<br>" + trn("{0} has updated GPS data.", "{0} have updated GPS data.", newdata, newdata) : "")
441 + "</html>";
442 }
443
444 @Override public Object getInfoComponent() {
445 return infoText();
446 }
447
448 @Override
449 public String getToolTipText() {
450 return infoText();
451 }
452
453 /**
454 * Determines if data managed by this layer has been modified. That is
455 * the case if one image has modified GPS data.
456 * @return {@code true} if data has been modified; {@code false}, otherwise
457 */
458 @Override
459 public boolean isModified() {
460 return this.data.isModified();
461 }
462
463 @Override
464 public boolean isMergable(Layer other) {
465 return other instanceof GeoImageLayer;
466 }
467
468 @Override
469 public void mergeFrom(Layer from) {
470 if (!(from instanceof GeoImageLayer))
471 throw new IllegalArgumentException("not a GeoImageLayer: " + from);
472 GeoImageLayer l = (GeoImageLayer) from;
473
474 // Stop to load thumbnails on both layers. Thumbnail loading will continue the next time
475 // the layer is painted.
476 stopLoadThumbs();
477 l.stopLoadThumbs();
478
479 this.data.mergeFrom(l.getImageData());
480
481 setName(l.getName());
482 thumbsLoaded &= l.thumbsLoaded;
483 }
484
485 private static Dimension scaledDimension(Image thumb) {
486 final double d = MainApplication.getMap().mapView.getDist100Pixel();
487 final double size = 10 /*meter*/; /* size of the photo on the map */
488 double s = size * 100 /*px*/ / d;
489
490 final double sMin = ThumbsLoader.minSize;
491 final double sMax = ThumbsLoader.maxSize;
492
493 if (s < sMin) {
494 s = sMin;
495 }
496 if (s > sMax) {
497 s = sMax;
498 }
499 final double f = s / sMax; /* scale factor */
500
501 if (thumb == null)
502 return null;
503
504 return new Dimension(
505 (int) Math.round(f * thumb.getWidth(null)),
506 (int) Math.round(f * thumb.getHeight(null)));
507 }
508
509 /**
510 * Paint one image.
511 * @param e Image to be painted
512 * @param mv Map view
513 * @param clip Bounding rectangle of the current clipping area
514 * @param tempG Temporary offscreen buffer
515 */
516 private void paintImage(ImageEntry e, MapView mv, Rectangle clip, Graphics2D tempG) {
517 if (e.getPos() == null) {
518 return;
519 }
520 Point p = mv.getPoint(e.getPos());
521 if (e.hasThumbnail()) {
522 Dimension d = scaledDimension(e.getThumbnail());
523 if (d != null) {
524 Rectangle target = new Rectangle(p.x - d.width / 2, p.y - d.height / 2, d.width, d.height);
525 if (clip.intersects(target)) {
526 tempG.drawImage(e.getThumbnail(), target.x, target.y, target.width, target.height, null);
527 }
528 }
529 } else { // thumbnail not loaded yet
530 icon.paintIcon(mv, tempG,
531 p.x - icon.getIconWidth() / 2,
532 p.y - icon.getIconHeight() / 2);
533 }
534 }
535
536 @Override
537 public void paint(Graphics2D g, MapView mv, Bounds bounds) {
538 int width = mv.getWidth();
539 int height = mv.getHeight();
540 Rectangle clip = g.getClipBounds();
541 if (useThumbs) {
542 if (!thumbsLoaded) {
543 startLoadThumbs();
544 }
545
546 if (null == offscreenBuffer || offscreenBuffer.getWidth() != width // reuse the old buffer if possible
547 || offscreenBuffer.getHeight() != height) {
548 offscreenBuffer = new BufferedImage(width, height,
549 BufferedImage.TYPE_INT_ARGB);
550 updateOffscreenBuffer = true;
551 }
552
553 if (updateOffscreenBuffer) {
554 Graphics2D tempG = offscreenBuffer.createGraphics();
555 tempG.setColor(new Color(0, 0, 0, 0));
556 Composite saveComp = tempG.getComposite();
557 tempG.setComposite(AlphaComposite.Clear); // remove the old images
558 tempG.fillRect(0, 0, width, height);
559 tempG.setComposite(saveComp);
560
561 for (ImageEntry e : data.getImages()) {
562 paintImage(e, mv, clip, tempG);
563 }
564 for (ImageEntry img: this.data.getSelectedImages()) {
565 // Make sure the selected image is on top in case multiple images overlap.
566 paintImage(img, mv, clip, tempG);
567 }
568 updateOffscreenBuffer = false;
569 }
570 g.drawImage(offscreenBuffer, 0, 0, null);
571 } else {
572 for (ImageEntry e : data.getImages()) {
573 if (e.getPos() == null) {
574 continue;
575 }
576 Point p = mv.getPoint(e.getPos());
577 icon.paintIcon(mv, g,
578 p.x - icon.getIconWidth() / 2,
579 p.y - icon.getIconHeight() / 2);
580 }
581 }
582
583 for (ImageEntry e: data.getSelectedImages()) {
584 if (e != null && e.getPos() != null) {
585 Point p = mv.getPoint(e.getPos());
586
587 int imgWidth;
588 int imgHeight;
589 if (useThumbs && e.hasThumbnail()) {
590 Dimension d = scaledDimension(e.getThumbnail());
591 if (d != null) {
592 imgWidth = d.width;
593 imgHeight = d.height;
594 } else {
595 imgWidth = -1;
596 imgHeight = -1;
597 }
598 } else {
599 imgWidth = selectedIcon.getIconWidth();
600 imgHeight = selectedIcon.getIconHeight();
601 }
602
603 if (e.getExifImgDir() != null) {
604 // Multiplier must be larger than sqrt(2)/2=0.71.
605 double arrowlength = Math.max(25, Math.max(imgWidth, imgHeight) * 0.85);
606 double arrowwidth = arrowlength / 1.4;
607
608 double dir = e.getExifImgDir();
609 // Rotate 90 degrees CCW
610 double headdir = (dir < 90) ? dir + 270 : dir - 90;
611 double leftdir = (headdir < 90) ? headdir + 270 : headdir - 90;
612 double rightdir = (headdir > 270) ? headdir - 270 : headdir + 90;
613
614 double ptx = p.x + Math.cos(Utils.toRadians(headdir)) * arrowlength;
615 double pty = p.y + Math.sin(Utils.toRadians(headdir)) * arrowlength;
616
617 double ltx = p.x + Math.cos(Utils.toRadians(leftdir)) * arrowwidth/2;
618 double lty = p.y + Math.sin(Utils.toRadians(leftdir)) * arrowwidth/2;
619
620 double rtx = p.x + Math.cos(Utils.toRadians(rightdir)) * arrowwidth/2;
621 double rty = p.y + Math.sin(Utils.toRadians(rightdir)) * arrowwidth/2;
622
623 g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
624 g.setColor(new Color(255, 255, 255, 192));
625 int[] xar = {(int) ltx, (int) ptx, (int) rtx, (int) ltx};
626 int[] yar = {(int) lty, (int) pty, (int) rty, (int) lty};
627 g.fillPolygon(xar, yar, 4);
628 g.setColor(Color.black);
629 g.setStroke(new BasicStroke(1.2f));
630 g.drawPolyline(xar, yar, 3);
631 }
632
633 if (useThumbs && e.hasThumbnail()) {
634 g.setColor(new Color(128, 0, 0, 122));
635 g.fillRect(p.x - imgWidth / 2, p.y - imgHeight / 2, imgWidth, imgHeight);
636 } else {
637 selectedIcon.paintIcon(mv, g,
638 p.x - imgWidth / 2,
639 p.y - imgHeight / 2);
640 }
641 }
642 }
643 }
644
645 @Override
646 public void visitBoundingBox(BoundingXYVisitor v) {
647 for (ImageEntry e : data.getImages()) {
648 v.visit(e.getPos());
649 }
650 }
651
652 /**
653 * Show current photo on map and in image viewer.
654 */
655 public void showCurrentPhoto() {
656 if (data.getSelectedImage() != null) {
657 clearOtherCurrentPhotos();
658 }
659 updateBufferAndRepaint();
660 }
661
662 /**
663 * Check if the position of the mouse event is within the rectangle of the photo icon or thumbnail.
664 * @param idx the image index
665 * @param evt Mouse event
666 * @return {@code true} if the photo matches the mouse position, {@code false} otherwise
667 */
668 private boolean isPhotoIdxUnderMouse(int idx, MouseEvent evt) {
669 ImageEntry img = data.getImages().get(idx);
670 if (img.getPos() != null) {
671 Point imgCenter = MainApplication.getMap().mapView.getPoint(img.getPos());
672 Rectangle imgRect;
673 if (useThumbs && img.hasThumbnail()) {
674 Dimension imgDim = scaledDimension(img.getThumbnail());
675 if (imgDim != null) {
676 imgRect = new Rectangle(imgCenter.x - imgDim.width / 2,
677 imgCenter.y - imgDim.height / 2,
678 imgDim.width, imgDim.height);
679 } else {
680 imgRect = null;
681 }
682 } else {
683 imgRect = new Rectangle(imgCenter.x - icon.getIconWidth() / 2,
684 imgCenter.y - icon.getIconHeight() / 2,
685 icon.getIconWidth(), icon.getIconHeight());
686 }
687 if (imgRect != null && imgRect.contains(evt.getPoint())) {
688 return true;
689 }
690 }
691 return false;
692 }
693
694 /**
695 * Returns index of the image that matches the position of the mouse event.
696 * @param evt Mouse event
697 * @param cycle Set to {@code true} to cycle through the photos at the
698 * current mouse position if multiple icons or thumbnails overlap.
699 * If set to {@code false} the topmost photo will be used.
700 * @return Image index at mouse position, range 0 .. size-1,
701 * or {@code -1} if there is no image at the mouse position
702 */
703 private int getPhotoIdxUnderMouse(MouseEvent evt, boolean cycle) {
704 ImageEntry selectedImage = data.getSelectedImage();
705 int selectedIndex = data.getImages().indexOf(selectedImage);
706
707 if (cycle && selectedImage != null) {
708 // Cycle loop is forward as that is the natural order.
709 // Loop 1: One after current photo up to last one.
710 for (int idx = selectedIndex + 1; idx < data.getImages().size(); ++idx) {
711 if (isPhotoIdxUnderMouse(idx, evt)) {
712 return idx;
713 }
714 }
715 // Loop 2: First photo up to current one.
716 for (int idx = 0; idx <= selectedIndex; ++idx) {
717 if (isPhotoIdxUnderMouse(idx, evt)) {
718 return idx;
719 }
720 }
721 } else {
722 // Check for current photo first, i.e. keep it selected if it is under the mouse.
723 if (selectedImage != null && isPhotoIdxUnderMouse(selectedIndex, evt)) {
724 return selectedIndex;
725 }
726 // Loop from last to first to prefer topmost image.
727 for (int idx = data.getImages().size() - 1; idx >= 0; --idx) {
728 if (isPhotoIdxUnderMouse(idx, evt)) {
729 return idx;
730 }
731 }
732 }
733 return -1;
734 }
735
736 /**
737 * Returns index of the image that matches the position of the mouse event.
738 * The topmost photo is picked if multiple icons or thumbnails overlap.
739 * @param evt Mouse event
740 * @return Image index at mouse position, range 0 .. size-1,
741 * or {@code -1} if there is no image at the mouse position
742 */
743 private int getPhotoIdxUnderMouse(MouseEvent evt) {
744 return getPhotoIdxUnderMouse(evt, false);
745 }
746
747 /**
748 * Returns the image that matches the position of the mouse event.
749 * The topmost photo is picked of multiple icons or thumbnails overlap.
750 * @param evt Mouse event
751 * @return Image at mouse position, or {@code null} if there is no image at the mouse position
752 * @since 6392
753 */
754 public ImageEntry getPhotoUnderMouse(MouseEvent evt) {
755 int idx = getPhotoIdxUnderMouse(evt);
756 if (idx >= 0) {
757 return data.getImages().get(idx);
758 } else {
759 return null;
760 }
761 }
762
763 /**
764 * Clears the currentPhoto of the other GeoImageLayer's. Otherwise there could be multiple selected photos.
765 */
766 private void clearOtherCurrentPhotos() {
767 for (GeoImageLayer layer:
768 MainApplication.getLayerManager().getLayersOfType(GeoImageLayer.class)) {
769 if (layer != this) {
770 layer.getImageData().clearSelectedImage();
771 }
772 }
773 }
774
775 /**
776 * Registers a map mode for which the functionality of this layer should be available.
777 * @param mapMode Map mode to be registered
778 * @since 6392
779 */
780 public static void registerSupportedMapMode(MapMode mapMode) {
781 if (supportedMapModes == null) {
782 supportedMapModes = new ArrayList<>();
783 }
784 supportedMapModes.add(mapMode);
785 }
786
787 /**
788 * Determines if the functionality of this layer is available in
789 * the specified map mode. {@link SelectAction} and {@link SelectLassoAction} are supported by default,
790 * other map modes can be registered.
791 * @param mapMode Map mode to be checked
792 * @return {@code true} if the map mode is supported,
793 * {@code false} otherwise
794 */
795 private static boolean isSupportedMapMode(MapMode mapMode) {
796 if (mapMode instanceof SelectAction || mapMode instanceof SelectLassoAction) {
797 return true;
798 }
799 return supportedMapModes != null && supportedMapModes.stream().anyMatch(supmmode -> mapMode == supmmode);
800 }
801
802 @Override
803 public void hookUpMapView() {
804 mouseAdapter = new ImageMouseListener();
805
806 mouseMotionAdapter = new MouseMotionAdapter() {
807 @Override
808 public void mouseMoved(MouseEvent evt) {
809 lastSelPos = null;
810 }
811
812 @Override
813 public void mouseDragged(MouseEvent evt) {
814 lastSelPos = null;
815 }
816 };
817
818 mapModeListener = (oldMapMode, newMapMode) -> {
819 MapView mapView = MainApplication.getMap().mapView;
820 if (newMapMode == null || isSupportedMapMode(newMapMode)) {
821 mapView.addMouseListener(mouseAdapter);
822 mapView.addMouseMotionListener(mouseMotionAdapter);
823 } else {
824 mapView.removeMouseListener(mouseAdapter);
825 mapView.removeMouseMotionListener(mouseMotionAdapter);
826 }
827 };
828
829 MapFrame.addMapModeChangeListener(mapModeListener);
830 mapModeListener.mapModeChange(null, MainApplication.getMap().mapMode);
831
832 activeLayerChangeListener = e -> {
833 if (MainApplication.getLayerManager().getActiveLayer() == this) {
834 // only in select mode it is possible to click the images
835 MainApplication.getMap().selectSelectTool(false);
836 }
837 };
838 MainApplication.getLayerManager().addActiveLayerChangeListener(activeLayerChangeListener);
839
840 MapFrame map = MainApplication.getMap();
841 if (map.getToggleDialog(ImageViewerDialog.class) == null) {
842 ImageViewerDialog.createInstance();
843 map.addToggleDialog(ImageViewerDialog.getInstance());
844 }
845 }
846
847 @Override
848 public synchronized void destroy() {
849 super.destroy();
850 stopLoadThumbs();
851 MapView mapView = MainApplication.getMap().mapView;
852 mapView.removeMouseListener(mouseAdapter);
853 mapView.removeMouseMotionListener(mouseMotionAdapter);
854 MapView.removeZoomChangeListener(this);
855 MapFrame.removeMapModeChangeListener(mapModeListener);
856 MainApplication.getLayerManager().removeActiveLayerChangeListener(activeLayerChangeListener);
857 data.removeImageDataUpdateListener(this);
858 }
859
860 @Override
861 public LayerPainter attachToMapView(MapViewEvent event) {
862 MapView.addZoomChangeListener(this);
863 return new CompatibilityModeLayerPainter() {
864 @Override
865 public void detachFromMapView(MapViewEvent event) {
866 MapView.removeZoomChangeListener(GeoImageLayer.this);
867 }
868 };
869 }
870
871 @Override
872 public void zoomChanged() {
873 updateBufferAndRepaint();
874 }
875
876 /**
877 * Start to load thumbnails.
878 */
879 public synchronized void startLoadThumbs() {
880 if (useThumbs && !thumbsLoaded && !thumbsLoaderRunning) {
881 stopLoadThumbs();
882 thumbsloader = new ThumbsLoader(this);
883 thumbsLoaderExecutor.submit(thumbsloader);
884 thumbsLoaderRunning = true;
885 }
886 }
887
888 /**
889 * Stop to load thumbnails.
890 *
891 * Can be called at any time to make sure that the
892 * thumbnail loader is stopped.
893 */
894 public synchronized void stopLoadThumbs() {
895 if (thumbsloader != null) {
896 thumbsloader.stop = true;
897 }
898 thumbsLoaderRunning = false;
899 }
900
901 /**
902 * Called to signal that the loading of thumbnails has finished.
903 *
904 * Usually called from {@link ThumbsLoader} in another thread.
905 */
906 public void thumbsLoaded() {
907 thumbsLoaded = true;
908 }
909
910 /**
911 * Marks the offscreen buffer to be updated.
912 */
913 public void updateBufferAndRepaint() {
914 updateOffscreenBuffer = true;
915 invalidate();
916 }
917
918 /**
919 * Get list of images in layer.
920 * @return List of images in layer
921 */
922 public List<ImageEntry> getImages() {
923 return new ArrayList<>(data.getImages());
924 }
925
926 /**
927 * Returns the image data store being used by this layer
928 * @return imageData
929 * @since 14590
930 */
931 public ImageData getImageData() {
932 return data;
933 }
934
935 /**
936 * Returns the associated GPX layer.
937 * @return The associated GPX layer
938 */
939 public GpxLayer getGpxLayer() {
940 return gpxLayer;
941 }
942
943 /**
944 * Returns a faux GPX layer built from the images or the associated GPX layer.
945 * @return A faux GPX layer or the associated GPX layer
946 * @since 14802
947 */
948 public synchronized GpxLayer getFauxGpxLayer() {
949 if (gpxLayer != null) return getGpxLayer();
950 if (gpxFauxLayer == null) {
951 GpxData gpxData = new GpxData();
952 List<ImageEntry> imageList = data.getImages();
953 for (ImageEntry image : imageList) {
954 WayPoint twaypoint = new WayPoint(image.getPos());
955 gpxData.addWaypoint(twaypoint);
956 }
957 gpxFauxLayer = new GpxLayer(gpxData);
958 }
959 return gpxFauxLayer;
960 }
961
962 @Override
963 public void jumpToNextMarker() {
964 data.selectNextImage();
965 }
966
967 @Override
968 public void jumpToPreviousMarker() {
969 data.selectPreviousImage();
970 }
971
972 /**
973 * Returns the current thumbnail display status.
974 * {@code true}: thumbnails are displayed, {@code false}: an icon is displayed instead of thumbnails.
975 * @return Current thumbnail display status
976 * @since 6392
977 */
978 public boolean isUseThumbs() {
979 return useThumbs;
980 }
981
982 /**
983 * Enables or disables the display of thumbnails. Does not update the display.
984 * @param useThumbs New thumbnail display status
985 * @since 6392
986 */
987 public void setUseThumbs(boolean useThumbs) {
988 this.useThumbs = useThumbs;
989 if (useThumbs && !thumbsLoaded) {
990 startLoadThumbs();
991 } else if (!useThumbs) {
992 stopLoadThumbs();
993 }
994 invalidate();
995 }
996
997 @Override
998 public void selectedImageChanged(ImageData data) {
999 showCurrentPhoto();
1000 }
1001
1002 @Override
1003 public void imageDataUpdated(ImageData data) {
1004 updateBufferAndRepaint();
1005 }
1006
1007 @Override
1008 public String getChangesetSourceTag() {
1009 return "Geotagged Images";
1010 }
1011}
Note: See TracBrowser for help on using the repository browser.