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

Last change on this file since 14472 was 14472, checked in by GerdP, 5 years ago

see #17040 call removeZoomChangeListener in destroy()

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