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