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