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