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

Last change on this file since 13264 was 13264, checked in by Don-vip, 6 years ago

see #15709 - proper singleton for ImageryViewerDialog

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