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

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

fix #19510 - Add "Zoom to layer" in context menu of layers in the Layers panel

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