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