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.event.MouseMotionAdapter; 020import java.awt.image.BufferedImage; 021import java.io.File; 022import java.io.IOException; 023import java.util.ArrayList; 024import java.util.Arrays; 025import java.util.Collection; 026import java.util.HashSet; 027import java.util.LinkedHashSet; 028import java.util.LinkedList; 029import java.util.List; 030import java.util.Set; 031import java.util.concurrent.ExecutorService; 032import java.util.concurrent.Executors; 033 034import javax.swing.Action; 035import javax.swing.Icon; 036import javax.swing.JOptionPane; 037 038import org.openstreetmap.josm.actions.LassoModeAction; 039import org.openstreetmap.josm.actions.RenameLayerAction; 040import org.openstreetmap.josm.actions.mapmode.MapMode; 041import org.openstreetmap.josm.actions.mapmode.SelectAction; 042import org.openstreetmap.josm.data.Bounds; 043import org.openstreetmap.josm.data.ImageData; 044import org.openstreetmap.josm.data.ImageData.ImageDataUpdateListener; 045import org.openstreetmap.josm.data.osm.visitor.BoundingXYVisitor; 046import org.openstreetmap.josm.gui.MainApplication; 047import org.openstreetmap.josm.gui.MapFrame; 048import org.openstreetmap.josm.gui.MapFrame.MapModeChangeListener; 049import org.openstreetmap.josm.gui.MapView; 050import org.openstreetmap.josm.gui.NavigatableComponent; 051import org.openstreetmap.josm.gui.PleaseWaitRunnable; 052import org.openstreetmap.josm.gui.dialogs.LayerListDialog; 053import org.openstreetmap.josm.gui.dialogs.LayerListPopup; 054import org.openstreetmap.josm.gui.io.importexport.JpgImporter; 055import org.openstreetmap.josm.gui.layer.AbstractModifiableLayer; 056import org.openstreetmap.josm.gui.layer.GpxLayer; 057import org.openstreetmap.josm.gui.layer.JumpToMarkerActions.JumpToMarkerLayer; 058import org.openstreetmap.josm.gui.layer.JumpToMarkerActions.JumpToNextMarker; 059import org.openstreetmap.josm.gui.layer.JumpToMarkerActions.JumpToPreviousMarker; 060import org.openstreetmap.josm.gui.layer.Layer; 061import org.openstreetmap.josm.gui.layer.MainLayerManager.ActiveLayerChangeListener; 062import org.openstreetmap.josm.tools.ImageProvider; 063import org.openstreetmap.josm.tools.Logging; 064import org.openstreetmap.josm.tools.Utils; 065 066/** 067 * Layer displaying geottaged pictures. 068 */ 069public class GeoImageLayer extends AbstractModifiableLayer implements 070 JumpToMarkerLayer, NavigatableComponent.ZoomChangeListener, ImageDataUpdateListener { 071 072 private static List<Action> menuAdditions = new LinkedList<>(); 073 074 private static volatile List<MapMode> supportedMapModes; 075 076 private final ImageData data; 077 GpxLayer gpxLayer; 078 079 private final Icon icon = ImageProvider.get("dialogs/geoimage/photo-marker"); 080 private final Icon selectedIcon = ImageProvider.get("dialogs/geoimage/photo-marker-selected"); 081 082 boolean useThumbs; 083 private final ExecutorService thumbsLoaderExecutor = 084 Executors.newSingleThreadExecutor(Utils.newThreadFactory("thumbnail-loader-%d", Thread.MIN_PRIORITY)); 085 private ThumbsLoader thumbsloader; 086 private boolean thumbsLoaderRunning; 087 volatile boolean thumbsLoaded; 088 private BufferedImage offscreenBuffer; 089 private boolean updateOffscreenBuffer = true; 090 091 private MouseAdapter mouseAdapter; 092 private MouseMotionAdapter mouseMotionAdapter; 093 private MapModeChangeListener mapModeListener; 094 private ActiveLayerChangeListener activeLayerChangeListener; 095 096 /** Mouse position where the last image was selected. */ 097 private Point lastSelPos; 098 099 /** 100 * Image cycle mode flag. 101 * It is possible that a mouse button release triggers multiple mouseReleased() events. 102 * To prevent the cycling in such a case we wait for the next mouse button press event 103 * before it is cycled to the next image. 104 */ 105 private boolean cycleModeArmed; 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 */ 112 public GeoImageLayer(final List<ImageEntry> data, GpxLayer gpxLayer) { 113 this(data, gpxLayer, null, false); 114 } 115 116 /** 117 * Constructs a new {@code GeoImageLayer}. 118 * @param data The list of images to display 119 * @param gpxLayer The associated GPX layer 120 * @param name Layer name 121 * @since 6392 122 */ 123 public GeoImageLayer(final List<ImageEntry> data, GpxLayer gpxLayer, final String name) { 124 this(data, gpxLayer, name, false); 125 } 126 127 /** 128 * Constructs a new {@code GeoImageLayer}. 129 * @param data The list of images to display 130 * @param gpxLayer The associated GPX layer 131 * @param useThumbs Thumbnail display flag 132 * @since 6392 133 */ 134 public GeoImageLayer(final List<ImageEntry> data, GpxLayer gpxLayer, boolean useThumbs) { 135 this(data, gpxLayer, null, useThumbs); 136 } 137 138 /** 139 * Constructs a new {@code GeoImageLayer}. 140 * @param data The list of images to display 141 * @param gpxLayer The associated GPX layer 142 * @param name Layer name 143 * @param useThumbs Thumbnail display flag 144 * @since 6392 145 */ 146 public GeoImageLayer(final List<ImageEntry> data, GpxLayer gpxLayer, final String name, boolean useThumbs) { 147 super(name != null ? name : tr("Geotagged Images")); 148 this.data = new ImageData(data); 149 this.gpxLayer = gpxLayer; 150 this.useThumbs = useThumbs; 151 this.data.addImageDataUpdateListener(this); 152 } 153 154 /** 155 * Loads a set of images, while displaying a dialog that indicates what the plugin is currently doing. 156 * In facts, this object is instantiated with a list of files. These files may be JPEG files or 157 * directories. In case of directories, they are scanned to find all the images they contain. 158 * Then all the images that have be found are loaded as ImageEntry instances. 159 */ 160 static final class Loader extends PleaseWaitRunnable { 161 162 private boolean canceled; 163 private GeoImageLayer layer; 164 private final Collection<File> selection; 165 private final Set<String> loadedDirectories = new HashSet<>(); 166 private final Set<String> errorMessages; 167 private final GpxLayer gpxLayer; 168 169 Loader(Collection<File> selection, GpxLayer gpxLayer) { 170 super(tr("Extracting GPS locations from EXIF")); 171 this.selection = selection; 172 this.gpxLayer = gpxLayer; 173 errorMessages = new LinkedHashSet<>(); 174 } 175 176 private void rememberError(String message) { 177 this.errorMessages.add(message); 178 } 179 180 @Override 181 protected void realRun() throws IOException { 182 183 progressMonitor.subTask(tr("Starting directory scan")); 184 Collection<File> files = new ArrayList<>(); 185 try { 186 addRecursiveFiles(files, selection); 187 } catch (IllegalStateException e) { 188 Logging.debug(e); 189 rememberError(e.getMessage()); 190 } 191 192 if (canceled) 193 return; 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 Logging.error(e); 235 rememberError(tr("Unable to get canonical path for directory {0}\n", 236 f.getAbsolutePath())); 237 } 238 239 if (canonical == null || loadedDirectories.contains(canonical)) { 240 continue; 241 } else { 242 loadedDirectories.add(canonical); 243 } 244 245 File[] children = f.listFiles(JpgImporter.FILE_FILTER_WITH_FOLDERS); 246 if (children != null) { 247 progressMonitor.subTask(tr("Scanning directory {0}", f.getPath())); 248 addRecursiveFiles(files, Arrays.asList(children)); 249 } else { 250 rememberError(tr("Error while getting files from directory {0}\n", f.getPath())); 251 } 252 253 } else { 254 files.add(f); 255 } 256 } 257 258 if (nullFile) { 259 throw new IllegalStateException(tr("One of the selected files was null")); 260 } 261 } 262 263 private String formatErrorMessages() { 264 StringBuilder sb = new StringBuilder(); 265 sb.append("<html>"); 266 if (errorMessages.size() == 1) { 267 sb.append(Utils.escapeReservedCharactersHTML(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 MainApplication.getMainFrame(), 279 formatErrorMessages(), 280 tr("Error"), 281 JOptionPane.ERROR_MESSAGE 282 ); 283 } 284 if (layer != null) { 285 MainApplication.getLayerManager().addLayer(layer); 286 287 if (!canceled && !layer.getImageData().getImages().isEmpty()) { 288 boolean noGeotagFound = true; 289 for (ImageEntry e : layer.getImageData().getImages()) { 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 /** 307 * Create a GeoImageLayer asynchronously 308 * @param files the list of image files to display 309 * @param gpxLayer the gpx layer 310 */ 311 public static void create(Collection<File> files, GpxLayer gpxLayer) { 312 MainApplication.worker.execute(new Loader(files, gpxLayer)); 313 } 314 315 @Override 316 public Icon getIcon() { 317 return ImageProvider.get("dialogs/geoimage", ImageProvider.ImageSizes.LAYER); 318 } 319 320 /** 321 * Register actions on the layer 322 * @param addition the action to be added 323 */ 324 public static void registerMenuAddition(Action addition) { 325 menuAdditions.add(addition); 326 } 327 328 @Override 329 public Action[] getMenuEntries() { 330 331 List<Action> entries = new ArrayList<>(); 332 entries.add(LayerListDialog.getInstance().createShowHideLayerAction()); 333 entries.add(LayerListDialog.getInstance().createDeleteLayerAction()); 334 entries.add(LayerListDialog.getInstance().createMergeLayerAction(this)); 335 entries.add(new RenameLayerAction(null, this)); 336 entries.add(SeparatorLayerAction.INSTANCE); 337 entries.add(new CorrelateGpxWithImages(this)); 338 entries.add(new ShowThumbnailAction(this)); 339 if (!menuAdditions.isEmpty()) { 340 entries.add(SeparatorLayerAction.INSTANCE); 341 entries.addAll(menuAdditions); 342 } 343 entries.add(SeparatorLayerAction.INSTANCE); 344 entries.add(new JumpToNextMarker(this)); 345 entries.add(new JumpToPreviousMarker(this)); 346 entries.add(SeparatorLayerAction.INSTANCE); 347 entries.add(new LayerListPopup.InfoAction(this)); 348 349 return entries.toArray(new Action[0]); 350 351 } 352 353 /** 354 * Prepare the string that is displayed if layer information is requested. 355 * @return String with layer information 356 */ 357 private String infoText() { 358 int tagged = 0; 359 int newdata = 0; 360 int n = data.getImages().size(); 361 for (ImageEntry e : data.getImages()) { 362 if (e.getPos() != null) { 363 tagged++; 364 } 365 if (e.hasNewGpsData()) { 366 newdata++; 367 } 368 } 369 return "<html>" 370 + trn("{0} image loaded.", "{0} images loaded.", n, n) 371 + ' ' + trn("{0} was found to be GPS tagged.", "{0} were found to be GPS tagged.", tagged, tagged) 372 + (newdata > 0 ? "<br>" + trn("{0} has updated GPS data.", "{0} have updated GPS data.", newdata, newdata) : "") 373 + "</html>"; 374 } 375 376 @Override public Object getInfoComponent() { 377 return infoText(); 378 } 379 380 @Override 381 public String getToolTipText() { 382 return infoText(); 383 } 384 385 /** 386 * Determines if data managed by this layer has been modified. That is 387 * the case if one image has modified GPS data. 388 * @return {@code true} if data has been modified; {@code false}, otherwise 389 */ 390 @Override 391 public boolean isModified() { 392 return this.data.isModified(); 393 } 394 395 @Override 396 public boolean isMergable(Layer other) { 397 return other instanceof GeoImageLayer; 398 } 399 400 @Override 401 public void mergeFrom(Layer from) { 402 if (!(from instanceof GeoImageLayer)) 403 throw new IllegalArgumentException("not a GeoImageLayer: " + from); 404 GeoImageLayer l = (GeoImageLayer) from; 405 406 // Stop to load thumbnails on both layers. Thumbnail loading will continue the next time 407 // the layer is painted. 408 stopLoadThumbs(); 409 l.stopLoadThumbs(); 410 411 this.data.mergeFrom(l.getImageData()); 412 413 setName(l.getName()); 414 thumbsLoaded &= l.thumbsLoaded; 415 } 416 417 private static Dimension scaledDimension(Image thumb) { 418 final double d = MainApplication.getMap().mapView.getDist100Pixel(); 419 final double size = 10 /*meter*/; /* size of the photo on the map */ 420 double s = size * 100 /*px*/ / d; 421 422 final double sMin = ThumbsLoader.minSize; 423 final double sMax = ThumbsLoader.maxSize; 424 425 if (s < sMin) { 426 s = sMin; 427 } 428 if (s > sMax) { 429 s = sMax; 430 } 431 final double f = s / sMax; /* scale factor */ 432 433 if (thumb == null) 434 return null; 435 436 return new Dimension( 437 (int) Math.round(f * thumb.getWidth(null)), 438 (int) Math.round(f * thumb.getHeight(null))); 439 } 440 441 /** 442 * Paint one image. 443 * @param e Image to be painted 444 * @param mv Map view 445 * @param clip Bounding rectangle of the current clipping area 446 * @param tempG Temporary offscreen buffer 447 */ 448 private void paintImage(ImageEntry e, MapView mv, Rectangle clip, Graphics2D tempG) { 449 if (e.getPos() == null) { 450 return; 451 } 452 Point p = mv.getPoint(e.getPos()); 453 if (e.hasThumbnail()) { 454 Dimension d = scaledDimension(e.getThumbnail()); 455 if (d != null) { 456 Rectangle target = new Rectangle(p.x - d.width / 2, p.y - d.height / 2, d.width, d.height); 457 if (clip.intersects(target)) { 458 tempG.drawImage(e.getThumbnail(), target.x, target.y, target.width, target.height, null); 459 } 460 } 461 } else { // thumbnail not loaded yet 462 icon.paintIcon(mv, tempG, 463 p.x - icon.getIconWidth() / 2, 464 p.y - icon.getIconHeight() / 2); 465 } 466 } 467 468 @Override 469 public void paint(Graphics2D g, MapView mv, Bounds bounds) { 470 int width = mv.getWidth(); 471 int height = mv.getHeight(); 472 Rectangle clip = g.getClipBounds(); 473 if (useThumbs) { 474 if (!thumbsLoaded) { 475 startLoadThumbs(); 476 } 477 478 if (null == offscreenBuffer || offscreenBuffer.getWidth() != width // reuse the old buffer if possible 479 || offscreenBuffer.getHeight() != height) { 480 offscreenBuffer = new BufferedImage(width, height, 481 BufferedImage.TYPE_INT_ARGB); 482 updateOffscreenBuffer = true; 483 } 484 485 if (updateOffscreenBuffer) { 486 Graphics2D tempG = offscreenBuffer.createGraphics(); 487 tempG.setColor(new Color(0, 0, 0, 0)); 488 Composite saveComp = tempG.getComposite(); 489 tempG.setComposite(AlphaComposite.Clear); // remove the old images 490 tempG.fillRect(0, 0, width, height); 491 tempG.setComposite(saveComp); 492 493 for (ImageEntry e : data.getImages()) { 494 paintImage(e, mv, clip, tempG); 495 } 496 if (data.getSelectedImage() != null) { 497 // Make sure the selected image is on top in case multiple images overlap. 498 paintImage(data.getSelectedImage(), mv, clip, tempG); 499 } 500 updateOffscreenBuffer = false; 501 } 502 g.drawImage(offscreenBuffer, 0, 0, null); 503 } else { 504 for (ImageEntry e : data.getImages()) { 505 if (e.getPos() == null) { 506 continue; 507 } 508 Point p = mv.getPoint(e.getPos()); 509 icon.paintIcon(mv, g, 510 p.x - icon.getIconWidth() / 2, 511 p.y - icon.getIconHeight() / 2); 512 } 513 } 514 515 ImageEntry e = data.getSelectedImage(); 516 if (e != null && e.getPos() != null) { 517 Point p = mv.getPoint(e.getPos()); 518 519 int imgWidth; 520 int imgHeight; 521 if (useThumbs && e.hasThumbnail()) { 522 Dimension d = scaledDimension(e.getThumbnail()); 523 if (d != null) { 524 imgWidth = d.width; 525 imgHeight = d.height; 526 } else { 527 imgWidth = -1; 528 imgHeight = -1; 529 } 530 } else { 531 imgWidth = selectedIcon.getIconWidth(); 532 imgHeight = selectedIcon.getIconHeight(); 533 } 534 535 if (e.getExifImgDir() != null) { 536 // Multiplier must be larger than sqrt(2)/2=0.71. 537 double arrowlength = Math.max(25, Math.max(imgWidth, imgHeight) * 0.85); 538 double arrowwidth = arrowlength / 1.4; 539 540 double dir = e.getExifImgDir(); 541 // Rotate 90 degrees CCW 542 double headdir = (dir < 90) ? dir + 270 : dir - 90; 543 double leftdir = (headdir < 90) ? headdir + 270 : headdir - 90; 544 double rightdir = (headdir > 270) ? headdir - 270 : headdir + 90; 545 546 double ptx = p.x + Math.cos(Utils.toRadians(headdir)) * arrowlength; 547 double pty = p.y + Math.sin(Utils.toRadians(headdir)) * arrowlength; 548 549 double ltx = p.x + Math.cos(Utils.toRadians(leftdir)) * arrowwidth/2; 550 double lty = p.y + Math.sin(Utils.toRadians(leftdir)) * arrowwidth/2; 551 552 double rtx = p.x + Math.cos(Utils.toRadians(rightdir)) * arrowwidth/2; 553 double rty = p.y + Math.sin(Utils.toRadians(rightdir)) * arrowwidth/2; 554 555 g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); 556 g.setColor(new Color(255, 255, 255, 192)); 557 int[] xar = {(int) ltx, (int) ptx, (int) rtx, (int) ltx}; 558 int[] yar = {(int) lty, (int) pty, (int) rty, (int) lty}; 559 g.fillPolygon(xar, yar, 4); 560 g.setColor(Color.black); 561 g.setStroke(new BasicStroke(1.2f)); 562 g.drawPolyline(xar, yar, 3); 563 } 564 565 if (useThumbs && e.hasThumbnail()) { 566 g.setColor(new Color(128, 0, 0, 122)); 567 g.fillRect(p.x - imgWidth / 2, p.y - imgHeight / 2, imgWidth, imgHeight); 568 } else { 569 selectedIcon.paintIcon(mv, g, 570 p.x - imgWidth / 2, 571 p.y - imgHeight / 2); 572 } 573 } 574 } 575 576 @Override 577 public void visitBoundingBox(BoundingXYVisitor v) { 578 for (ImageEntry e : data.getImages()) { 579 v.visit(e.getPos()); 580 } 581 } 582 583 /** 584 * Show current photo on map and in image viewer. 585 */ 586 public void showCurrentPhoto() { 587 if (data.getSelectedImage() != null) { 588 clearOtherCurrentPhotos(); 589 } 590 updateBufferAndRepaint(); 591 } 592 593 /** 594 * Check if the position of the mouse event is within the rectangle of the photo icon or thumbnail. 595 * @param idx the image index 596 * @param evt Mouse event 597 * @return {@code true} if the photo matches the mouse position, {@code false} otherwise 598 */ 599 private boolean isPhotoIdxUnderMouse(int idx, MouseEvent evt) { 600 ImageEntry img = data.getImages().get(idx); 601 if (img.getPos() != null) { 602 Point imgCenter = MainApplication.getMap().mapView.getPoint(img.getPos()); 603 Rectangle imgRect; 604 if (useThumbs && img.hasThumbnail()) { 605 Dimension imgDim = scaledDimension(img.getThumbnail()); 606 if (imgDim != null) { 607 imgRect = new Rectangle(imgCenter.x - imgDim.width / 2, 608 imgCenter.y - imgDim.height / 2, 609 imgDim.width, imgDim.height); 610 } else { 611 imgRect = null; 612 } 613 } else { 614 imgRect = new Rectangle(imgCenter.x - icon.getIconWidth() / 2, 615 imgCenter.y - icon.getIconHeight() / 2, 616 icon.getIconWidth(), icon.getIconHeight()); 617 } 618 if (imgRect != null && imgRect.contains(evt.getPoint())) { 619 return true; 620 } 621 } 622 return false; 623 } 624 625 /** 626 * Returns index of the image that matches the position of the mouse event. 627 * @param evt Mouse event 628 * @param cycle Set to {@code true} to cycle through the photos at the 629 * current mouse position if multiple icons or thumbnails overlap. 630 * If set to {@code false} the topmost photo will be used. 631 * @return Image index at mouse position, range 0 .. size-1, 632 * or {@code -1} if there is no image at the mouse position 633 */ 634 private int getPhotoIdxUnderMouse(MouseEvent evt, boolean cycle) { 635 ImageEntry selectedImage = data.getSelectedImage(); 636 int selectedIndex = data.getImages().indexOf(selectedImage); 637 638 if (cycle && selectedImage != null) { 639 // Cycle loop is forward as that is the natural order. 640 // Loop 1: One after current photo up to last one. 641 for (int idx = selectedIndex + 1; idx < data.getImages().size(); ++idx) { 642 if (isPhotoIdxUnderMouse(idx, evt)) { 643 return idx; 644 } 645 } 646 // Loop 2: First photo up to current one. 647 for (int idx = 0; idx <= selectedIndex; ++idx) { 648 if (isPhotoIdxUnderMouse(idx, evt)) { 649 return idx; 650 } 651 } 652 } else { 653 // Check for current photo first, i.e. keep it selected if it is under the mouse. 654 if (selectedImage != null && isPhotoIdxUnderMouse(selectedIndex, evt)) { 655 return selectedIndex; 656 } 657 // Loop from last to first to prefer topmost image. 658 for (int idx = data.getImages().size() - 1; idx >= 0; --idx) { 659 if (isPhotoIdxUnderMouse(idx, evt)) { 660 return idx; 661 } 662 } 663 } 664 return -1; 665 } 666 667 /** 668 * Returns index of the image that matches the position of the mouse event. 669 * The topmost photo is picked if multiple icons or thumbnails overlap. 670 * @param evt Mouse event 671 * @return Image index at mouse position, range 0 .. size-1, 672 * or {@code -1} if there is no image at the mouse position 673 */ 674 private int getPhotoIdxUnderMouse(MouseEvent evt) { 675 return getPhotoIdxUnderMouse(evt, false); 676 } 677 678 /** 679 * Returns the image that matches the position of the mouse event. 680 * The topmost photo is picked of multiple icons or thumbnails overlap. 681 * @param evt Mouse event 682 * @return Image at mouse position, or {@code null} if there is no image at the mouse position 683 * @since 6392 684 */ 685 public ImageEntry getPhotoUnderMouse(MouseEvent evt) { 686 int idx = getPhotoIdxUnderMouse(evt); 687 if (idx >= 0) { 688 return data.getImages().get(idx); 689 } else { 690 return null; 691 } 692 } 693 694 /** 695 * Clears the currentPhoto, i.e. remove select marker, and optionally repaint. 696 * @param repaint Repaint flag 697 * @deprecated Use {@link ImageData#clearSelectedImage} 698 * @since 6392 699 */ 700 @Deprecated 701 public void clearCurrentPhoto(boolean repaint) { 702 data.clearSelectedImage(); 703 if (repaint) { 704 updateBufferAndRepaint(); 705 } 706 } 707 708 /** 709 * Clears the currentPhoto of the other GeoImageLayer's. Otherwise there could be multiple selected photos. 710 */ 711 private void clearOtherCurrentPhotos() { 712 for (GeoImageLayer layer: 713 MainApplication.getLayerManager().getLayersOfType(GeoImageLayer.class)) { 714 if (layer != this) { 715 layer.getImageData().clearSelectedImage(); 716 } 717 } 718 } 719 720 /** 721 * Registers a map mode for which the functionality of this layer should be available. 722 * @param mapMode Map mode to be registered 723 * @since 6392 724 */ 725 public static void registerSupportedMapMode(MapMode mapMode) { 726 if (supportedMapModes == null) { 727 supportedMapModes = new ArrayList<>(); 728 } 729 supportedMapModes.add(mapMode); 730 } 731 732 /** 733 * Determines if the functionality of this layer is available in 734 * the specified map mode. {@link SelectAction} and {@link LassoModeAction} are supported by default, 735 * other map modes can be registered. 736 * @param mapMode Map mode to be checked 737 * @return {@code true} if the map mode is supported, 738 * {@code false} otherwise 739 */ 740 private static boolean isSupportedMapMode(MapMode mapMode) { 741 if (mapMode instanceof SelectAction || mapMode instanceof LassoModeAction) { 742 return true; 743 } 744 if (supportedMapModes != null) { 745 for (MapMode supmmode: supportedMapModes) { 746 if (mapMode == supmmode) { 747 return true; 748 } 749 } 750 } 751 return false; 752 } 753 754 @Override 755 public void hookUpMapView() { 756 mouseAdapter = new MouseAdapter() { 757 private boolean isMapModeOk() { 758 MapMode mapMode = MainApplication.getMap().mapMode; 759 return mapMode == null || isSupportedMapMode(mapMode); 760 } 761 762 @Override 763 public void mousePressed(MouseEvent e) { 764 if (e.getButton() != MouseEvent.BUTTON1) 765 return; 766 if (isVisible() && isMapModeOk()) { 767 cycleModeArmed = true; 768 invalidate(); 769 } 770 } 771 772 @Override 773 public void mouseReleased(MouseEvent ev) { 774 if (ev.getButton() != MouseEvent.BUTTON1) 775 return; 776 if (!isVisible() || !isMapModeOk()) 777 return; 778 779 Point mousePos = ev.getPoint(); 780 boolean cycle = cycleModeArmed && lastSelPos != null && lastSelPos.equals(mousePos); 781 int idx = getPhotoIdxUnderMouse(ev, cycle); 782 if (idx >= 0) { 783 lastSelPos = mousePos; 784 cycleModeArmed = false; 785 data.setSelectedImage(data.getImages().get(idx)); 786 } 787 } 788 }; 789 790 mouseMotionAdapter = new MouseMotionAdapter() { 791 @Override 792 public void mouseMoved(MouseEvent evt) { 793 lastSelPos = null; 794 } 795 796 @Override 797 public void mouseDragged(MouseEvent evt) { 798 lastSelPos = null; 799 } 800 }; 801 802 mapModeListener = (oldMapMode, newMapMode) -> { 803 MapView mapView = MainApplication.getMap().mapView; 804 if (newMapMode == null || isSupportedMapMode(newMapMode)) { 805 mapView.addMouseListener(mouseAdapter); 806 mapView.addMouseMotionListener(mouseMotionAdapter); 807 } else { 808 mapView.removeMouseListener(mouseAdapter); 809 mapView.removeMouseMotionListener(mouseMotionAdapter); 810 } 811 }; 812 813 MapFrame.addMapModeChangeListener(mapModeListener); 814 mapModeListener.mapModeChange(null, MainApplication.getMap().mapMode); 815 816 activeLayerChangeListener = e -> { 817 if (MainApplication.getLayerManager().getActiveLayer() == this) { 818 // only in select mode it is possible to click the images 819 MainApplication.getMap().selectSelectTool(false); 820 } 821 }; 822 MainApplication.getLayerManager().addActiveLayerChangeListener(activeLayerChangeListener); 823 824 MapFrame map = MainApplication.getMap(); 825 if (map.getToggleDialog(ImageViewerDialog.class) == null) { 826 ImageViewerDialog.createInstance(); 827 map.addToggleDialog(ImageViewerDialog.getInstance()); 828 } 829 } 830 831 @Override 832 public synchronized void destroy() { 833 super.destroy(); 834 stopLoadThumbs(); 835 MapView mapView = MainApplication.getMap().mapView; 836 mapView.removeMouseListener(mouseAdapter); 837 mapView.removeMouseMotionListener(mouseMotionAdapter); 838 MapView.removeZoomChangeListener(this); 839 MapFrame.removeMapModeChangeListener(mapModeListener); 840 MainApplication.getLayerManager().removeActiveLayerChangeListener(activeLayerChangeListener); 841 data.removeImageDataUpdateListener(this); 842 } 843 844 @Override 845 public LayerPainter attachToMapView(MapViewEvent event) { 846 MapView.addZoomChangeListener(this); 847 return new CompatibilityModeLayerPainter() { 848 @Override 849 public void detachFromMapView(MapViewEvent event) { 850 MapView.removeZoomChangeListener(GeoImageLayer.this); 851 } 852 }; 853 } 854 855 @Override 856 public void zoomChanged() { 857 updateBufferAndRepaint(); 858 } 859 860 /** 861 * Start to load thumbnails. 862 */ 863 public synchronized void startLoadThumbs() { 864 if (useThumbs && !thumbsLoaded && !thumbsLoaderRunning) { 865 stopLoadThumbs(); 866 thumbsloader = new ThumbsLoader(this); 867 thumbsLoaderExecutor.submit(thumbsloader); 868 thumbsLoaderRunning = true; 869 } 870 } 871 872 /** 873 * Stop to load thumbnails. 874 * 875 * Can be called at any time to make sure that the 876 * thumbnail loader is stopped. 877 */ 878 public synchronized void stopLoadThumbs() { 879 if (thumbsloader != null) { 880 thumbsloader.stop = true; 881 } 882 thumbsLoaderRunning = false; 883 } 884 885 /** 886 * Called to signal that the loading of thumbnails has finished. 887 * 888 * Usually called from {@link ThumbsLoader} in another thread. 889 */ 890 public void thumbsLoaded() { 891 thumbsLoaded = true; 892 } 893 894 /** 895 * Marks the offscreen buffer to be updated. 896 */ 897 public void updateBufferAndRepaint() { 898 updateOffscreenBuffer = true; 899 invalidate(); 900 } 901 902 /** 903 * Get list of images in layer. 904 * @return List of images in layer 905 */ 906 public List<ImageEntry> getImages() { 907 return new ArrayList<>(data.getImages()); 908 } 909 910 /** 911 * Returns the image data store being used by this layer 912 * @return imageData 913 * @since 14590 914 */ 915 public ImageData getImageData() { 916 return data; 917 } 918 919 /** 920 * Returns the associated GPX layer. 921 * @return The associated GPX layer 922 */ 923 public GpxLayer getGpxLayer() { 924 return gpxLayer; 925 } 926 927 @Override 928 public void jumpToNextMarker() { 929 data.selectNextImage(); 930 } 931 932 @Override 933 public void jumpToPreviousMarker() { 934 data.selectPreviousImage(); 935 } 936 937 /** 938 * Returns the current thumbnail display status. 939 * {@code true}: thumbnails are displayed, {@code false}: an icon is displayed instead of thumbnails. 940 * @return Current thumbnail display status 941 * @since 6392 942 */ 943 public boolean isUseThumbs() { 944 return useThumbs; 945 } 946 947 /** 948 * Enables or disables the display of thumbnails. Does not update the display. 949 * @param useThumbs New thumbnail display status 950 * @since 6392 951 */ 952 public void setUseThumbs(boolean useThumbs) { 953 this.useThumbs = useThumbs; 954 if (useThumbs && !thumbsLoaded) { 955 startLoadThumbs(); 956 } else if (!useThumbs) { 957 stopLoadThumbs(); 958 } 959 invalidate(); 960 } 961 962 @Override 963 public void selectedImageChanged(ImageData data) { 964 showCurrentPhoto(); 965 } 966 967 @Override 968 public void imageDataUpdated(ImageData data) { 969 updateBufferAndRepaint(); 970 } 971}