001// License: GPL. For details, see LICENSE file. 002package org.openstreetmap.josm.gui.layer.geoimage; 003 004import static org.openstreetmap.josm.tools.I18n.tr; 005import static org.openstreetmap.josm.tools.I18n.trn; 006 007import java.awt.AlphaComposite; 008import java.awt.BasicStroke; 009import java.awt.Color; 010import java.awt.Composite; 011import java.awt.Dimension; 012import java.awt.Graphics2D; 013import java.awt.Image; 014import java.awt.Point; 015import java.awt.Rectangle; 016import java.awt.RenderingHints; 017import java.awt.event.MouseAdapter; 018import java.awt.event.MouseEvent; 019import java.awt.event.MouseMotionAdapter; 020import java.awt.image.BufferedImage; 021import java.io.File; 022import java.io.IOException; 023import java.util.ArrayList; 024import java.util.Arrays; 025import java.util.Collection; 026import java.util.Collections; 027import java.util.HashSet; 028import java.util.LinkedHashSet; 029import java.util.LinkedList; 030import java.util.List; 031import java.util.Set; 032import java.util.concurrent.ExecutorService; 033import java.util.concurrent.Executors; 034 035import javax.swing.Action; 036import javax.swing.Icon; 037import javax.swing.JLabel; 038import javax.swing.JOptionPane; 039import javax.swing.SwingConstants; 040 041import org.openstreetmap.josm.Main; 042import org.openstreetmap.josm.actions.LassoModeAction; 043import org.openstreetmap.josm.actions.RenameLayerAction; 044import org.openstreetmap.josm.actions.mapmode.MapMode; 045import org.openstreetmap.josm.actions.mapmode.SelectAction; 046import org.openstreetmap.josm.data.Bounds; 047import org.openstreetmap.josm.data.osm.visitor.BoundingXYVisitor; 048import org.openstreetmap.josm.gui.ExtendedDialog; 049import org.openstreetmap.josm.gui.MainApplication; 050import org.openstreetmap.josm.gui.MapFrame; 051import org.openstreetmap.josm.gui.MapFrame.MapModeChangeListener; 052import org.openstreetmap.josm.gui.MapView; 053import org.openstreetmap.josm.gui.NavigatableComponent; 054import org.openstreetmap.josm.gui.PleaseWaitRunnable; 055import org.openstreetmap.josm.gui.datatransfer.ClipboardUtils; 056import org.openstreetmap.josm.gui.dialogs.LayerListDialog; 057import org.openstreetmap.josm.gui.dialogs.LayerListPopup; 058import org.openstreetmap.josm.gui.io.importexport.JpgImporter; 059import org.openstreetmap.josm.gui.layer.AbstractModifiableLayer; 060import org.openstreetmap.josm.gui.layer.GpxLayer; 061import org.openstreetmap.josm.gui.layer.JumpToMarkerActions.JumpToMarkerLayer; 062import org.openstreetmap.josm.gui.layer.JumpToMarkerActions.JumpToNextMarker; 063import org.openstreetmap.josm.gui.layer.JumpToMarkerActions.JumpToPreviousMarker; 064import org.openstreetmap.josm.gui.layer.Layer; 065import org.openstreetmap.josm.gui.layer.MainLayerManager.ActiveLayerChangeListener; 066import org.openstreetmap.josm.gui.util.GuiHelper; 067import org.openstreetmap.josm.tools.ImageProvider; 068import org.openstreetmap.josm.tools.Logging; 069import org.openstreetmap.josm.tools.Utils; 070 071/** 072 * Layer displaying geottaged pictures. 073 */ 074public class GeoImageLayer extends AbstractModifiableLayer implements 075 JumpToMarkerLayer, NavigatableComponent.ZoomChangeListener { 076 077 private static List<Action> menuAdditions = new LinkedList<>(); 078 079 private static volatile List<MapMode> supportedMapModes; 080 081 List<ImageEntry> data; 082 GpxLayer gpxLayer; 083 084 private final Icon icon = ImageProvider.get("dialogs/geoimage/photo-marker"); 085 private final Icon selectedIcon = ImageProvider.get("dialogs/geoimage/photo-marker-selected"); 086 087 private int currentPhoto = -1; 088 089 boolean useThumbs; 090 private final ExecutorService thumbsLoaderExecutor = 091 Executors.newSingleThreadExecutor(Utils.newThreadFactory("thumbnail-loader-%d", Thread.MIN_PRIORITY)); 092 private ThumbsLoader thumbsloader; 093 private boolean thumbsLoaderRunning; 094 volatile boolean thumbsLoaded; 095 private BufferedImage offscreenBuffer; 096 private boolean updateOffscreenBuffer = true; 097 098 private MouseAdapter mouseAdapter; 099 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 ImageProvider.get("dialogs/geoimage", ImageProvider.ImageSizes.LAYER); 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}