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