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