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.dialogs.LayerListDialog; 056import org.openstreetmap.josm.gui.dialogs.LayerListPopup; 057import org.openstreetmap.josm.gui.layer.AbstractModifiableLayer; 058import org.openstreetmap.josm.gui.layer.GpxLayer; 059import org.openstreetmap.josm.gui.layer.JumpToMarkerActions.JumpToMarkerLayer; 060import org.openstreetmap.josm.gui.layer.JumpToMarkerActions.JumpToNextMarker; 061import org.openstreetmap.josm.gui.layer.JumpToMarkerActions.JumpToPreviousMarker; 062import org.openstreetmap.josm.gui.layer.Layer; 063import org.openstreetmap.josm.gui.layer.LayerManager.LayerAddEvent; 064import org.openstreetmap.josm.gui.layer.LayerManager.LayerChangeListener; 065import org.openstreetmap.josm.gui.layer.LayerManager.LayerOrderChangeEvent; 066import org.openstreetmap.josm.gui.layer.LayerManager.LayerRemoveEvent; 067import org.openstreetmap.josm.gui.layer.MainLayerManager.ActiveLayerChangeEvent; 068import org.openstreetmap.josm.gui.layer.MainLayerManager.ActiveLayerChangeListener; 069import org.openstreetmap.josm.gui.util.GuiHelper; 070import org.openstreetmap.josm.io.JpgImporter; 071import org.openstreetmap.josm.tools.ImageProvider; 072import org.openstreetmap.josm.tools.Utils; 073 074/** 075 * Layer displaying geottaged pictures. 076 */ 077public class GeoImageLayer extends AbstractModifiableLayer implements PropertyChangeListener, JumpToMarkerLayer { 078 079 private static List<Action> menuAdditions = new LinkedList<>(); 080 081 private static volatile List<MapMode> supportedMapModes; 082 083 List<ImageEntry> data; 084 GpxLayer gpxLayer; 085 086 private final Icon icon = ImageProvider.get("dialogs/geoimage/photo-marker"); 087 private final Icon selectedIcon = ImageProvider.get("dialogs/geoimage/photo-marker-selected"); 088 089 private int currentPhoto = -1; 090 091 boolean useThumbs; 092 private final ExecutorService thumbsLoaderExecutor = 093 Executors.newSingleThreadExecutor(Utils.newThreadFactory("thumbnail-loader-%d", Thread.MIN_PRIORITY)); 094 private ThumbsLoader thumbsloader; 095 private boolean thumbsLoaderRunning; 096 volatile boolean thumbsLoaded; 097 private BufferedImage offscreenBuffer; 098 boolean updateOffscreenBuffer = true; 099 100 private MouseAdapter mouseAdapter; 101 private MapModeChangeListener mapModeListener; 102 103 /** 104 * Constructs a new {@code GeoImageLayer}. 105 * @param data The list of images to display 106 * @param gpxLayer The associated GPX layer 107 */ 108 public GeoImageLayer(final List<ImageEntry> data, GpxLayer gpxLayer) { 109 this(data, gpxLayer, null, false); 110 } 111 112 /** 113 * Constructs a new {@code GeoImageLayer}. 114 * @param data The list of images to display 115 * @param gpxLayer The associated GPX layer 116 * @param name Layer name 117 * @since 6392 118 */ 119 public GeoImageLayer(final List<ImageEntry> data, GpxLayer gpxLayer, final String name) { 120 this(data, gpxLayer, name, 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 useThumbs Thumbnail display flag 128 * @since 6392 129 */ 130 public GeoImageLayer(final List<ImageEntry> data, GpxLayer gpxLayer, boolean useThumbs) { 131 this(data, gpxLayer, null, useThumbs); 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 name Layer name 139 * @param useThumbs Thumbnail display flag 140 * @since 6392 141 */ 142 public GeoImageLayer(final List<ImageEntry> data, GpxLayer gpxLayer, final String name, boolean useThumbs) { 143 super(name != null ? name : tr("Geotagged Images")); 144 if (data != null) { 145 Collections.sort(data); 146 } 147 this.data = data; 148 this.gpxLayer = gpxLayer; 149 this.useThumbs = useThumbs; 150 } 151 152 /** 153 * Loads a set of images, while displaying a dialog that indicates what the plugin is currently doing. 154 * In facts, this object is instantiated with a list of files. These files may be JPEG files or 155 * directories. In case of directories, they are scanned to find all the images they contain. 156 * Then all the images that have be found are loaded as ImageEntry instances. 157 */ 158 static final class Loader extends PleaseWaitRunnable { 159 160 private boolean canceled; 161 private GeoImageLayer layer; 162 private final Collection<File> selection; 163 private final Set<String> loadedDirectories = new HashSet<>(); 164 private final Set<String> errorMessages; 165 private final GpxLayer gpxLayer; 166 167 Loader(Collection<File> selection, GpxLayer gpxLayer) { 168 super(tr("Extracting GPS locations from EXIF")); 169 this.selection = selection; 170 this.gpxLayer = gpxLayer; 171 errorMessages = new LinkedHashSet<>(); 172 } 173 174 protected void rememberError(String message) { 175 this.errorMessages.add(message); 176 } 177 178 @Override 179 protected void realRun() throws IOException { 180 181 progressMonitor.subTask(tr("Starting directory scan")); 182 Collection<File> files = new ArrayList<>(); 183 try { 184 addRecursiveFiles(files, selection); 185 } catch (IllegalStateException 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 protected 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(new Runnable() { 433 @Override 434 public void run() { 435 for (int i = 0; i < data.size(); i++) { 436 if (selected.equals(data.get(i))) { 437 currentPhoto = i; 438 ImageViewerDialog.showImage(GeoImageLayer.this, data.get(i)); 439 break; 440 } 441 } 442 } 443 }); 444 } 445 446 setName(l.getName()); 447 thumbsLoaded &= l.thumbsLoaded; 448 } 449 450 private static Dimension scaledDimension(Image thumb) { 451 final double d = Main.map.mapView.getDist100Pixel(); 452 final double size = 10 /*meter*/; /* size of the photo on the map */ 453 double s = size * 100 /*px*/ / d; 454 455 final double sMin = ThumbsLoader.minSize; 456 final double sMax = ThumbsLoader.maxSize; 457 458 if (s < sMin) { 459 s = sMin; 460 } 461 if (s > sMax) { 462 s = sMax; 463 } 464 final double f = s / sMax; /* scale factor */ 465 466 if (thumb == null) 467 return null; 468 469 return new Dimension( 470 (int) Math.round(f * thumb.getWidth(null)), 471 (int) Math.round(f * thumb.getHeight(null))); 472 } 473 474 @Override 475 public void paint(Graphics2D g, MapView mv, Bounds bounds) { 476 int width = mv.getWidth(); 477 int height = mv.getHeight(); 478 Rectangle clip = g.getClipBounds(); 479 if (useThumbs) { 480 if (!thumbsLoaded) { 481 startLoadThumbs(); 482 } 483 484 if (null == offscreenBuffer || offscreenBuffer.getWidth() != width // reuse the old buffer if possible 485 || offscreenBuffer.getHeight() != height) { 486 offscreenBuffer = new BufferedImage(width, height, 487 BufferedImage.TYPE_INT_ARGB); 488 updateOffscreenBuffer = true; 489 } 490 491 if (updateOffscreenBuffer) { 492 Graphics2D tempG = offscreenBuffer.createGraphics(); 493 tempG.setColor(new Color(0, 0, 0, 0)); 494 Composite saveComp = tempG.getComposite(); 495 tempG.setComposite(AlphaComposite.Clear); // remove the old images 496 tempG.fillRect(0, 0, width, height); 497 tempG.setComposite(saveComp); 498 499 if (data != null) { 500 for (ImageEntry e : data) { 501 if (e.getPos() == null) { 502 continue; 503 } 504 Point p = mv.getPoint(e.getPos()); 505 if (e.hasThumbnail()) { 506 Dimension d = scaledDimension(e.getThumbnail()); 507 Rectangle target = new Rectangle(p.x - d.width / 2, p.y - d.height / 2, d.width, d.height); 508 if (clip.intersects(target)) { 509 tempG.drawImage(e.getThumbnail(), target.x, target.y, target.width, target.height, null); 510 } 511 } else { // thumbnail not loaded yet 512 icon.paintIcon(mv, tempG, 513 p.x - icon.getIconWidth() / 2, 514 p.y - icon.getIconHeight() / 2); 515 } 516 } 517 } 518 updateOffscreenBuffer = false; 519 } 520 g.drawImage(offscreenBuffer, 0, 0, null); 521 } else if (data != null) { 522 for (ImageEntry e : data) { 523 if (e.getPos() == null) { 524 continue; 525 } 526 Point p = mv.getPoint(e.getPos()); 527 icon.paintIcon(mv, g, 528 p.x - icon.getIconWidth() / 2, 529 p.y - icon.getIconHeight() / 2); 530 } 531 } 532 533 if (currentPhoto >= 0 && currentPhoto < data.size()) { 534 ImageEntry e = data.get(currentPhoto); 535 536 if (e.getPos() != null) { 537 Point p = mv.getPoint(e.getPos()); 538 539 int imgWidth; 540 int imgHeight; 541 if (useThumbs && e.hasThumbnail()) { 542 Dimension d = scaledDimension(e.getThumbnail()); 543 imgWidth = d.width; 544 imgHeight = d.height; 545 } else { 546 imgWidth = selectedIcon.getIconWidth(); 547 imgHeight = selectedIcon.getIconHeight(); 548 } 549 550 if (e.getExifImgDir() != null) { 551 // Multiplier must be larger than sqrt(2)/2=0.71. 552 double arrowlength = Math.max(25, Math.max(imgWidth, imgHeight) * 0.85); 553 double arrowwidth = arrowlength / 1.4; 554 555 double dir = e.getExifImgDir(); 556 // Rotate 90 degrees CCW 557 double headdir = (dir < 90) ? dir + 270 : dir - 90; 558 double leftdir = (headdir < 90) ? headdir + 270 : headdir - 90; 559 double rightdir = (headdir > 270) ? headdir - 270 : headdir + 90; 560 561 double ptx = p.x + Math.cos(Math.toRadians(headdir)) * arrowlength; 562 double pty = p.y + Math.sin(Math.toRadians(headdir)) * arrowlength; 563 564 double ltx = p.x + Math.cos(Math.toRadians(leftdir)) * arrowwidth/2; 565 double lty = p.y + Math.sin(Math.toRadians(leftdir)) * arrowwidth/2; 566 567 double rtx = p.x + Math.cos(Math.toRadians(rightdir)) * arrowwidth/2; 568 double rty = p.y + Math.sin(Math.toRadians(rightdir)) * arrowwidth/2; 569 570 g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); 571 g.setColor(new Color(255, 255, 255, 192)); 572 int[] xar = {(int) ltx, (int) ptx, (int) rtx, (int) ltx}; 573 int[] yar = {(int) lty, (int) pty, (int) rty, (int) lty}; 574 g.fillPolygon(xar, yar, 4); 575 g.setColor(Color.black); 576 g.setStroke(new BasicStroke(1.2f)); 577 g.drawPolyline(xar, yar, 3); 578 } 579 580 if (useThumbs && e.hasThumbnail()) { 581 g.setColor(new Color(128, 0, 0, 122)); 582 g.fillRect(p.x - imgWidth / 2, p.y - imgHeight / 2, imgWidth, imgHeight); 583 } else { 584 selectedIcon.paintIcon(mv, g, 585 p.x - imgWidth / 2, 586 p.y - imgHeight / 2); 587 588 } 589 } 590 } 591 } 592 593 @Override 594 public void visitBoundingBox(BoundingXYVisitor v) { 595 for (ImageEntry e : data) { 596 v.visit(e.getPos()); 597 } 598 } 599 600 /** 601 * Shows next photo. 602 */ 603 public void showNextPhoto() { 604 if (data != null && !data.isEmpty()) { 605 currentPhoto++; 606 if (currentPhoto >= data.size()) { 607 currentPhoto = data.size() - 1; 608 } 609 ImageViewerDialog.showImage(this, data.get(currentPhoto)); 610 } else { 611 currentPhoto = -1; 612 } 613 Main.map.repaint(); 614 } 615 616 /** 617 * Shows previous photo. 618 */ 619 public void showPreviousPhoto() { 620 if (data != null && !data.isEmpty()) { 621 currentPhoto--; 622 if (currentPhoto < 0) { 623 currentPhoto = 0; 624 } 625 ImageViewerDialog.showImage(this, data.get(currentPhoto)); 626 } else { 627 currentPhoto = -1; 628 } 629 Main.map.repaint(); 630 } 631 632 /** 633 * Shows first photo. 634 */ 635 public void showFirstPhoto() { 636 if (data != null && !data.isEmpty()) { 637 currentPhoto = 0; 638 ImageViewerDialog.showImage(this, data.get(currentPhoto)); 639 } else { 640 currentPhoto = -1; 641 } 642 Main.map.repaint(); 643 } 644 645 /** 646 * Shows last photo. 647 */ 648 public void showLastPhoto() { 649 if (data != null && !data.isEmpty()) { 650 currentPhoto = data.size() - 1; 651 ImageViewerDialog.showImage(this, data.get(currentPhoto)); 652 } else { 653 currentPhoto = -1; 654 } 655 Main.map.repaint(); 656 } 657 658 public void checkPreviousNextButtons() { 659 ImageViewerDialog.setNextEnabled(data != null && currentPhoto < data.size() - 1); 660 ImageViewerDialog.setPreviousEnabled(currentPhoto > 0); 661 } 662 663 public void removeCurrentPhoto() { 664 if (data != null && !data.isEmpty() && currentPhoto >= 0 && currentPhoto < data.size()) { 665 data.remove(currentPhoto); 666 if (currentPhoto >= data.size()) { 667 currentPhoto = data.size() - 1; 668 } 669 if (currentPhoto >= 0) { 670 ImageViewerDialog.showImage(this, data.get(currentPhoto)); 671 } else { 672 ImageViewerDialog.showImage(this, null); 673 } 674 updateOffscreenBuffer = true; 675 Main.map.repaint(); 676 } 677 } 678 679 public void removeCurrentPhotoFromDisk() { 680 ImageEntry toDelete; 681 if (data != null && !data.isEmpty() && currentPhoto >= 0 && currentPhoto < data.size()) { 682 toDelete = data.get(currentPhoto); 683 684 int result = new ExtendedDialog( 685 Main.parent, 686 tr("Delete image file from disk"), 687 new String[] {tr("Cancel"), tr("Delete")}) 688 .setButtonIcons(new String[] {"cancel", "dialogs/delete"}) 689 .setContent(new JLabel(tr("<html><h3>Delete the file {0} from disk?<p>The image file will be permanently lost!</h3></html>", 690 toDelete.getFile().getName()), ImageProvider.get("dialogs/geoimage/deletefromdisk"), SwingConstants.LEFT)) 691 .toggleEnable("geoimage.deleteimagefromdisk") 692 .setCancelButton(1) 693 .setDefaultButton(2) 694 .showDialog() 695 .getValue(); 696 697 if (result == 2) { 698 data.remove(currentPhoto); 699 if (currentPhoto >= data.size()) { 700 currentPhoto = data.size() - 1; 701 } 702 if (currentPhoto >= 0) { 703 ImageViewerDialog.showImage(this, data.get(currentPhoto)); 704 } else { 705 ImageViewerDialog.showImage(this, null); 706 } 707 708 if (Utils.deleteFile(toDelete.getFile())) { 709 Main.info("File "+toDelete.getFile()+" deleted. "); 710 } else { 711 JOptionPane.showMessageDialog( 712 Main.parent, 713 tr("Image file could not be deleted."), 714 tr("Error"), 715 JOptionPane.ERROR_MESSAGE 716 ); 717 } 718 719 updateOffscreenBuffer = true; 720 Main.map.repaint(); 721 } 722 } 723 } 724 725 public void copyCurrentPhotoPath() { 726 if (data != null && !data.isEmpty() && currentPhoto >= 0 && currentPhoto < data.size()) { 727 Utils.copyToClipboard(data.get(currentPhoto).getFile().toString()); 728 } 729 } 730 731 /** 732 * Removes a photo from the list of images by index. 733 * @param idx Image index 734 * @since 6392 735 */ 736 public void removePhotoByIdx(int idx) { 737 if (idx >= 0 && data != null && idx < data.size()) { 738 data.remove(idx); 739 } 740 } 741 742 /** 743 * Returns the image that matches the position of the mouse event. 744 * @param evt Mouse event 745 * @return Image at mouse position, or {@code null} if there is no image at the mouse position 746 * @since 6392 747 */ 748 public ImageEntry getPhotoUnderMouse(MouseEvent evt) { 749 if (data != null) { 750 for (int idx = data.size() - 1; idx >= 0; --idx) { 751 ImageEntry img = data.get(idx); 752 if (img.getPos() == null) { 753 continue; 754 } 755 Point p = Main.map.mapView.getPoint(img.getPos()); 756 Rectangle r; 757 if (useThumbs && img.hasThumbnail()) { 758 Dimension d = scaledDimension(img.getThumbnail()); 759 r = new Rectangle(p.x - d.width / 2, p.y - d.height / 2, d.width, d.height); 760 } else { 761 r = new Rectangle(p.x - icon.getIconWidth() / 2, 762 p.y - icon.getIconHeight() / 2, 763 icon.getIconWidth(), 764 icon.getIconHeight()); 765 } 766 if (r.contains(evt.getPoint())) { 767 return img; 768 } 769 } 770 } 771 return null; 772 } 773 774 /** 775 * Clears the currentPhoto, i.e. remove select marker, and optionally repaint. 776 * @param repaint Repaint flag 777 * @since 6392 778 */ 779 public void clearCurrentPhoto(boolean repaint) { 780 currentPhoto = -1; 781 if (repaint) { 782 updateBufferAndRepaint(); 783 } 784 } 785 786 /** 787 * Clears the currentPhoto of the other GeoImageLayer's. Otherwise there could be multiple selected photos. 788 */ 789 private void clearOtherCurrentPhotos() { 790 for (GeoImageLayer layer: 791 Main.getLayerManager().getLayersOfType(GeoImageLayer.class)) { 792 if (layer != this) { 793 layer.clearCurrentPhoto(false); 794 } 795 } 796 } 797 798 /** 799 * Registers a map mode for which the functionality of this layer should be available. 800 * @param mapMode Map mode to be registered 801 * @since 6392 802 */ 803 public static void registerSupportedMapMode(MapMode mapMode) { 804 if (supportedMapModes == null) { 805 supportedMapModes = new ArrayList<>(); 806 } 807 supportedMapModes.add(mapMode); 808 } 809 810 /** 811 * Determines if the functionality of this layer is available in 812 * the specified map mode. {@link SelectAction} and {@link LassoModeAction} are supported by default, 813 * other map modes can be registered. 814 * @param mapMode Map mode to be checked 815 * @return {@code true} if the map mode is supported, 816 * {@code false} otherwise 817 */ 818 private static boolean isSupportedMapMode(MapMode mapMode) { 819 if (mapMode instanceof SelectAction || mapMode instanceof LassoModeAction) { 820 return true; 821 } 822 if (supportedMapModes != null) { 823 for (MapMode supmmode: supportedMapModes) { 824 if (mapMode == supmmode) { 825 return true; 826 } 827 } 828 } 829 return false; 830 } 831 832 @Override 833 public void hookUpMapView() { 834 mouseAdapter = new MouseAdapter() { 835 private boolean isMapModeOk() { 836 return Main.map.mapMode == null || isSupportedMapMode(Main.map.mapMode); 837 } 838 839 @Override 840 public void mousePressed(MouseEvent e) { 841 if (e.getButton() != MouseEvent.BUTTON1) 842 return; 843 if (isVisible() && isMapModeOk()) { 844 Main.map.mapView.repaint(); 845 } 846 } 847 848 @Override 849 public void mouseReleased(MouseEvent ev) { 850 if (ev.getButton() != MouseEvent.BUTTON1) 851 return; 852 if (data == null || !isVisible() || !isMapModeOk()) 853 return; 854 855 for (int i = data.size() - 1; i >= 0; --i) { 856 ImageEntry e = data.get(i); 857 if (e.getPos() == null) { 858 continue; 859 } 860 Point p = Main.map.mapView.getPoint(e.getPos()); 861 Rectangle r; 862 if (useThumbs && e.hasThumbnail()) { 863 Dimension d = scaledDimension(e.getThumbnail()); 864 r = new Rectangle(p.x - d.width / 2, p.y - d.height / 2, d.width, d.height); 865 } else { 866 r = new Rectangle(p.x - icon.getIconWidth() / 2, 867 p.y - icon.getIconHeight() / 2, 868 icon.getIconWidth(), 869 icon.getIconHeight()); 870 } 871 if (r.contains(ev.getPoint())) { 872 clearOtherCurrentPhotos(); 873 currentPhoto = i; 874 ImageViewerDialog.showImage(GeoImageLayer.this, e); 875 Main.map.repaint(); 876 break; 877 } 878 } 879 } 880 }; 881 882 mapModeListener = new MapModeChangeListener() { 883 @Override 884 public void mapModeChange(MapMode oldMapMode, MapMode newMapMode) { 885 if (newMapMode == null || isSupportedMapMode(newMapMode)) { 886 Main.map.mapView.addMouseListener(mouseAdapter); 887 } else { 888 Main.map.mapView.removeMouseListener(mouseAdapter); 889 } 890 } 891 }; 892 893 MapFrame.addMapModeChangeListener(mapModeListener); 894 mapModeListener.mapModeChange(null, Main.map.mapMode); 895 896 Main.getLayerManager().addActiveLayerChangeListener(new ActiveLayerChangeListener() { 897 @Override 898 public void activeOrEditLayerChanged(ActiveLayerChangeEvent e) { 899 if (Main.getLayerManager().getActiveLayer() == GeoImageLayer.this) { 900 // only in select mode it is possible to click the images 901 Main.map.selectSelectTool(false); 902 } 903 } 904 }); 905 906 Main.getLayerManager().addLayerChangeListener(new LayerChangeListener() { 907 @Override 908 public void layerAdded(LayerAddEvent e) { 909 // Do nothing 910 } 911 912 @Override 913 public void layerRemoving(LayerRemoveEvent e) { 914 if (e.getRemovedLayer() == GeoImageLayer.this) { 915 stopLoadThumbs(); 916 Main.map.mapView.removeMouseListener(mouseAdapter); 917 MapFrame.removeMapModeChangeListener(mapModeListener); 918 currentPhoto = -1; 919 if (data != null) { 920 data.clear(); 921 } 922 data = null; 923 // stop listening to layer change events 924 Main.getLayerManager().removeLayerChangeListener(this); 925 } 926 } 927 928 @Override 929 public void layerOrderChanged(LayerOrderChangeEvent e) { 930 // Do nothing 931 } 932 }); 933 934 Main.map.mapView.addPropertyChangeListener(this); 935 if (Main.map.getToggleDialog(ImageViewerDialog.class) == null) { 936 ImageViewerDialog.newInstance(); 937 Main.map.addToggleDialog(ImageViewerDialog.getInstance()); 938 } 939 } 940 941 @Override 942 public void propertyChange(PropertyChangeEvent evt) { 943 if (NavigatableComponent.PROPNAME_CENTER.equals(evt.getPropertyName()) || 944 NavigatableComponent.PROPNAME_SCALE.equals(evt.getPropertyName())) { 945 updateOffscreenBuffer = true; 946 } 947 } 948 949 /** 950 * Start to load thumbnails. 951 */ 952 public synchronized void startLoadThumbs() { 953 if (useThumbs && !thumbsLoaded && !thumbsLoaderRunning) { 954 stopLoadThumbs(); 955 thumbsloader = new ThumbsLoader(this); 956 thumbsLoaderExecutor.submit(thumbsloader); 957 thumbsLoaderRunning = true; 958 } 959 } 960 961 /** 962 * Stop to load thumbnails. 963 * 964 * Can be called at any time to make sure that the 965 * thumbnail loader is stopped. 966 */ 967 public synchronized void stopLoadThumbs() { 968 if (thumbsloader != null) { 969 thumbsloader.stop = true; 970 } 971 thumbsLoaderRunning = false; 972 } 973 974 /** 975 * Called to signal that the loading of thumbnails has finished. 976 * 977 * Usually called from {@link ThumbsLoader} in another thread. 978 */ 979 public void thumbsLoaded() { 980 thumbsLoaded = true; 981 } 982 983 public void updateBufferAndRepaint() { 984 updateOffscreenBuffer = true; 985 invalidate(); 986 } 987 988 /** 989 * Get list of images in layer. 990 * @return List of images in layer 991 */ 992 public List<ImageEntry> getImages() { 993 return data == null ? Collections.<ImageEntry>emptyList() : new ArrayList<>(data); 994 } 995 996 /** 997 * Returns the associated GPX layer. 998 * @return The associated GPX layer 999 */ 1000 public GpxLayer getGpxLayer() { 1001 return gpxLayer; 1002 } 1003 1004 @Override 1005 public void jumpToNextMarker() { 1006 showNextPhoto(); 1007 } 1008 1009 @Override 1010 public void jumpToPreviousMarker() { 1011 showPreviousPhoto(); 1012 } 1013 1014 /** 1015 * Returns the current thumbnail display status. 1016 * {@code true}: thumbnails are displayed, {@code false}: an icon is displayed instead of thumbnails. 1017 * @return Current thumbnail display status 1018 * @since 6392 1019 */ 1020 public boolean isUseThumbs() { 1021 return useThumbs; 1022 } 1023 1024 /** 1025 * Enables or disables the display of thumbnails. Does not update the display. 1026 * @param useThumbs New thumbnail display status 1027 * @since 6392 1028 */ 1029 public void setUseThumbs(boolean useThumbs) { 1030 this.useThumbs = useThumbs; 1031 if (useThumbs && !thumbsLoaded) { 1032 startLoadThumbs(); 1033 } else if (!useThumbs) { 1034 stopLoadThumbs(); 1035 } 1036 } 1037}