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