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