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