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