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

Last change on this file since 13375 was 13375, checked in by bastiK, 6 years ago

see #15240 - replace image for geoimage layer with svg version (from Tango icon project)

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