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 /** 359 * Prepare the string that is displayed if layer information is requested. 360 * @return String with layer information 361 */ 362 private String infoText() { 363 int tagged = 0; 364 int newdata = 0; 365 for (ImageEntry e : data) { 366 if (e.getPos() != null) { 367 tagged++; 368 } 369 if (e.hasNewGpsData()) { 370 newdata++; 371 } 372 } 373 return "<html>" 374 + trn("{0} image loaded.", "{0} images loaded.", data.size(), data.size()) 375 + " " + trn("{0} was found to be GPS tagged.", "{0} were found to be GPS tagged.", tagged, tagged) 376 + (newdata > 0 ? "<br>" + trn("{0} has updated GPS data.", "{0} have updated GPS data.", newdata, newdata) : "") 377 + "</html>"; 378 } 379 380 @Override public Object getInfoComponent() { 381 return infoText(); 382 } 383 384 @Override 385 public String getToolTipText() { 386 return infoText(); 387 } 388 389 @Override 390 public boolean isMergable(Layer other) { 391 return other instanceof GeoImageLayer; 392 } 393 394 @Override 395 public void mergeFrom(Layer from) { 396 GeoImageLayer l = (GeoImageLayer) from; 397 398 // Stop to load thumbnails on both layers. Thumbnail loading will continue the next time 399 // the layer is painted. 400 stopLoadThumbs(); 401 l.stopLoadThumbs(); 402 403 final ImageEntry selected = l.currentPhoto >= 0 ? l.data.get(l.currentPhoto) : null; 404 405 data.addAll(l.data); 406 Collections.sort(data); 407 408 // Supress the double photos. 409 if (data.size() > 1) { 410 ImageEntry cur; 411 ImageEntry prev = data.get(data.size() - 1); 412 for (int i = data.size() - 2; i >= 0; i--) { 413 cur = data.get(i); 414 if (cur.getFile().equals(prev.getFile())) { 415 data.remove(i); 416 } else { 417 prev = cur; 418 } 419 } 420 } 421 422 if (selected != null && !data.isEmpty()) { 423 GuiHelper.runInEDTAndWait(new Runnable() { 424 @Override 425 public void run() { 426 for (int i = 0; i < data.size() ; i++) { 427 if (selected.equals(data.get(i))) { 428 currentPhoto = i; 429 ImageViewerDialog.showImage(GeoImageLayer.this, data.get(i)); 430 break; 431 } 432 } 433 } 434 }); 435 } 436 437 setName(l.getName()); 438 thumbsLoaded &= l.thumbsLoaded; 439 } 440 441 private Dimension scaledDimension(Image thumb) { 442 final double d = Main.map.mapView.getDist100Pixel(); 443 final double size = 10 /*meter*/; /* size of the photo on the map */ 444 double s = size * 100 /*px*/ / d; 445 446 final double sMin = ThumbsLoader.minSize; 447 final double sMax = ThumbsLoader.maxSize; 448 449 if (s < sMin) { 450 s = sMin; 451 } 452 if (s > sMax) { 453 s = sMax; 454 } 455 final double f = s / sMax; /* scale factor */ 456 457 if (thumb == null) 458 return null; 459 460 return new Dimension( 461 (int) Math.round(f * thumb.getWidth(null)), 462 (int) Math.round(f * thumb.getHeight(null))); 463 } 464 465 @Override 466 public void paint(Graphics2D g, MapView mv, Bounds bounds) { 467 int width = mv.getWidth(); 468 int height = mv.getHeight(); 469 Rectangle clip = g.getClipBounds(); 470 if (useThumbs) { 471 if (!thumbsLoaded) { 472 startLoadThumbs(); 473 } 474 475 if (null == offscreenBuffer || offscreenBuffer.getWidth() != width // reuse the old buffer if possible 476 || offscreenBuffer.getHeight() != height) { 477 offscreenBuffer = new BufferedImage(width, height, 478 BufferedImage.TYPE_INT_ARGB); 479 updateOffscreenBuffer = true; 480 } 481 482 if (updateOffscreenBuffer) { 483 Graphics2D tempG = offscreenBuffer.createGraphics(); 484 tempG.setColor(new Color(0,0,0,0)); 485 Composite saveComp = tempG.getComposite(); 486 tempG.setComposite(AlphaComposite.Clear); // remove the old images 487 tempG.fillRect(0, 0, width, height); 488 tempG.setComposite(saveComp); 489 490 for (ImageEntry e : data) { 491 if (e.getPos() == null) { 492 continue; 493 } 494 Point p = mv.getPoint(e.getPos()); 495 if (e.thumbnail != null) { 496 Dimension d = scaledDimension(e.thumbnail); 497 Rectangle target = new Rectangle(p.x - d.width / 2, p.y - d.height / 2, d.width, d.height); 498 if (clip.intersects(target)) { 499 tempG.drawImage(e.thumbnail, target.x, target.y, target.width, target.height, null); 500 } 501 } 502 else { // thumbnail not loaded yet 503 icon.paintIcon(mv, tempG, 504 p.x - icon.getIconWidth() / 2, 505 p.y - icon.getIconHeight() / 2); 506 } 507 } 508 updateOffscreenBuffer = false; 509 } 510 g.drawImage(offscreenBuffer, 0, 0, null); 511 } 512 else { 513 for (ImageEntry e : data) { 514 if (e.getPos() == null) { 515 continue; 516 } 517 Point p = mv.getPoint(e.getPos()); 518 icon.paintIcon(mv, g, 519 p.x - icon.getIconWidth() / 2, 520 p.y - icon.getIconHeight() / 2); 521 } 522 } 523 524 if (currentPhoto >= 0 && currentPhoto < data.size()) { 525 ImageEntry e = data.get(currentPhoto); 526 527 if (e.getPos() != null) { 528 Point p = mv.getPoint(e.getPos()); 529 530 int imgWidth = 100; 531 int imgHeight = 100; 532 if (useThumbs && e.thumbnail != null) { 533 Dimension d = scaledDimension(e.thumbnail); 534 imgWidth = d.width; 535 imgHeight = d.height; 536 } 537 else { 538 imgWidth = selectedIcon.getIconWidth(); 539 imgHeight = selectedIcon.getIconHeight(); 540 } 541 542 if (e.getExifImgDir() != null) { 543 // Multiplier must be larger than sqrt(2)/2=0.71. 544 double arrowlength = Math.max(25, Math.max(imgWidth, imgHeight) * 0.85); 545 double arrowwidth = arrowlength / 1.4; 546 547 double dir = e.getExifImgDir(); 548 // Rotate 90 degrees CCW 549 double headdir = ( dir < 90 ) ? dir + 270 : dir - 90; 550 double leftdir = ( headdir < 90 ) ? headdir + 270 : headdir - 90; 551 double rightdir = ( headdir > 270 ) ? headdir - 270 : headdir + 90; 552 553 double ptx = p.x + Math.cos(Math.toRadians(headdir)) * arrowlength; 554 double pty = p.y + Math.sin(Math.toRadians(headdir)) * arrowlength; 555 556 double ltx = p.x + Math.cos(Math.toRadians(leftdir)) * arrowwidth/2; 557 double lty = p.y + Math.sin(Math.toRadians(leftdir)) * arrowwidth/2; 558 559 double rtx = p.x + Math.cos(Math.toRadians(rightdir)) * arrowwidth/2; 560 double rty = p.y + Math.sin(Math.toRadians(rightdir)) * arrowwidth/2; 561 562 g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON); 563 g.setColor(new Color(255, 255, 255, 192)); 564 int[] xar = {(int) ltx, (int) ptx, (int) rtx, (int) ltx}; 565 int[] yar = {(int) lty, (int) pty, (int) rty, (int) lty}; 566 g.fillPolygon(xar, yar, 4); 567 g.setColor(Color.black); 568 g.setStroke(new BasicStroke(1.2f)); 569 g.drawPolyline(xar, yar, 3); 570 } 571 572 if (useThumbs && e.thumbnail != null) { 573 g.setColor(new Color(128, 0, 0, 122)); 574 g.fillRect(p.x - imgWidth / 2, p.y - imgHeight / 2, imgWidth, imgHeight); 575 } else { 576 selectedIcon.paintIcon(mv, g, 577 p.x - imgWidth / 2, 578 p.y - imgHeight / 2); 579 580 } 581 } 582 } 583 } 584 585 @Override 586 public void visitBoundingBox(BoundingXYVisitor v) { 587 for (ImageEntry e : data) { 588 v.visit(e.getPos()); 589 } 590 } 591 592 /** 593 * Extract GPS metadata from image EXIF 594 * 595 * If successful, fills in the LatLon and EastNorth attributes of passed in image 596 */ 597 private static void extractExif(ImageEntry e) { 598 599 Metadata metadata; 600 Directory dirExif; 601 GpsDirectory dirGps; 602 603 try { 604 metadata = JpegMetadataReader.readMetadata(e.getFile()); 605 dirExif = metadata.getDirectory(ExifIFD0Directory.class); 606 dirGps = metadata.getDirectory(GpsDirectory.class); 607 } catch (CompoundException | IOException p) { 608 e.setExifCoor(null); 609 e.setPos(null); 610 return; 611 } 612 613 try { 614 if (dirExif != null) { 615 int orientation = dirExif.getInt(ExifIFD0Directory.TAG_ORIENTATION); 616 e.setExifOrientation(orientation); 617 } 618 } catch (MetadataException ex) { 619 Main.debug(ex.getMessage()); 620 } 621 622 if (dirGps == null) { 623 e.setExifCoor(null); 624 e.setPos(null); 625 return; 626 } 627 628 try { 629 double speed = dirGps.getDouble(GpsDirectory.TAG_GPS_SPEED); 630 String speedRef = dirGps.getString(GpsDirectory.TAG_GPS_SPEED_REF); 631 if (speedRef != null) { 632 if (speedRef.equalsIgnoreCase("M")) { 633 // miles per hour 634 speed *= 1.609344; 635 } else if (speedRef.equalsIgnoreCase("N")) { 636 // knots == nautical miles per hour 637 speed *= 1.852; 638 } 639 // default is K (km/h) 640 } 641 e.setSpeed(speed); 642 } catch (Exception ex) { 643 Main.debug(ex.getMessage()); 644 } 645 646 try { 647 double ele = dirGps.getDouble(GpsDirectory.TAG_GPS_ALTITUDE); 648 int d = dirGps.getInt(GpsDirectory.TAG_GPS_ALTITUDE_REF); 649 if (d == 1) { 650 ele *= -1; 651 } 652 e.setElevation(ele); 653 } catch (MetadataException ex) { 654 Main.debug(ex.getMessage()); 655 } 656 657 try { 658 LatLon latlon = ExifReader.readLatLon(dirGps); 659 e.setExifCoor(latlon); 660 e.setPos(e.getExifCoor()); 661 662 } catch (Exception ex) { // (other exceptions, e.g. #5271) 663 Main.error("Error reading EXIF from file: "+ex); 664 e.setExifCoor(null); 665 e.setPos(null); 666 } 667 668 try { 669 Double direction = ExifReader.readDirection(dirGps); 670 if (direction != null) { 671 e.setExifImgDir(direction.doubleValue()); 672 } 673 } catch (Exception ex) { // (CompoundException and other exceptions, e.g. #5271) 674 Main.debug(ex.getMessage()); 675 } 676 677 // Time and date. We can have these cases: 678 // 1) GPS_TIME_STAMP not set -> date/time will be null 679 // 2) GPS_DATE_STAMP not set -> use EXIF date or set to default 680 // 3) GPS_TIME_STAMP and GPS_DATE_STAMP are set 681 int[] timeStampComps = dirGps.getIntArray(GpsDirectory.TAG_GPS_TIME_STAMP); 682 if (timeStampComps != null) { 683 int gpsHour = timeStampComps[0]; 684 int gpsMin = timeStampComps[1]; 685 int gpsSec = timeStampComps[2]; 686 Calendar cal = new GregorianCalendar(TimeZone.getTimeZone("UTC")); 687 688 // We have the time. Next step is to check if the GPS date stamp is set. 689 // dirGps.getString() always succeeds, but the return value might be null. 690 String dateStampStr = dirGps.getString(GpsDirectory.TAG_GPS_DATE_STAMP); 691 if (dateStampStr != null && dateStampStr.matches("^\\d+:\\d+:\\d+$")) { 692 String[] dateStampComps = dateStampStr.split(":"); 693 cal.set(Calendar.YEAR, Integer.parseInt(dateStampComps[0])); 694 cal.set(Calendar.MONTH, Integer.parseInt(dateStampComps[1]) - 1); 695 cal.set(Calendar.DAY_OF_MONTH, Integer.parseInt(dateStampComps[2])); 696 } 697 else { 698 // No GPS date stamp in EXIF data. Copy it from EXIF time. 699 // Date is not set if EXIF time is not available. 700 if (e.hasExifTime()) { 701 // Time not set yet, so we can copy everything, not just date. 702 cal.setTime(e.getExifTime()); 703 } 704 } 705 706 cal.set(Calendar.HOUR_OF_DAY, gpsHour); 707 cal.set(Calendar.MINUTE, gpsMin); 708 cal.set(Calendar.SECOND, gpsSec); 709 710 e.setExifGpsTime(cal.getTime()); 711 } 712 } 713 714 public void showNextPhoto() { 715 if (data != null && data.size() > 0) { 716 currentPhoto++; 717 if (currentPhoto >= data.size()) { 718 currentPhoto = data.size() - 1; 719 } 720 ImageViewerDialog.showImage(this, data.get(currentPhoto)); 721 } else { 722 currentPhoto = -1; 723 } 724 Main.map.repaint(); 725 } 726 727 public void showPreviousPhoto() { 728 if (data != null && !data.isEmpty()) { 729 currentPhoto--; 730 if (currentPhoto < 0) { 731 currentPhoto = 0; 732 } 733 ImageViewerDialog.showImage(this, data.get(currentPhoto)); 734 } else { 735 currentPhoto = -1; 736 } 737 Main.map.repaint(); 738 } 739 740 public void showFirstPhoto() { 741 if (data != null && data.size() > 0) { 742 currentPhoto = 0; 743 ImageViewerDialog.showImage(this, data.get(currentPhoto)); 744 } else { 745 currentPhoto = -1; 746 } 747 Main.map.repaint(); 748 } 749 750 public void showLastPhoto() { 751 if (data != null && data.size() > 0) { 752 currentPhoto = data.size() - 1; 753 ImageViewerDialog.showImage(this, data.get(currentPhoto)); 754 } else { 755 currentPhoto = -1; 756 } 757 Main.map.repaint(); 758 } 759 760 public void checkPreviousNextButtons() { 761 ImageViewerDialog.setNextEnabled(currentPhoto < data.size() - 1); 762 ImageViewerDialog.setPreviousEnabled(currentPhoto > 0); 763 } 764 765 public void removeCurrentPhoto() { 766 if (data != null && data.size() > 0 && currentPhoto >= 0 && currentPhoto < data.size()) { 767 data.remove(currentPhoto); 768 if (currentPhoto >= data.size()) { 769 currentPhoto = data.size() - 1; 770 } 771 if (currentPhoto >= 0) { 772 ImageViewerDialog.showImage(this, data.get(currentPhoto)); 773 } else { 774 ImageViewerDialog.showImage(this, null); 775 } 776 updateOffscreenBuffer = true; 777 Main.map.repaint(); 778 } 779 } 780 781 public void removeCurrentPhotoFromDisk() { 782 ImageEntry toDelete = null; 783 if (data != null && data.size() > 0 && currentPhoto >= 0 && currentPhoto < data.size()) { 784 toDelete = data.get(currentPhoto); 785 786 int result = new ExtendedDialog( 787 Main.parent, 788 tr("Delete image file from disk"), 789 new String[] {tr("Cancel"), tr("Delete")}) 790 .setButtonIcons(new String[] {"cancel", "dialogs/delete"}) 791 .setContent(new JLabel(tr("<html><h3>Delete the file {0} from disk?<p>The image file will be permanently lost!</h3></html>" 792 ,toDelete.getFile().getName()), ImageProvider.get("dialogs/geoimage/deletefromdisk"),SwingConstants.LEFT)) 793 .toggleEnable("geoimage.deleteimagefromdisk") 794 .setCancelButton(1) 795 .setDefaultButton(2) 796 .showDialog() 797 .getValue(); 798 799 if(result == 2) 800 { 801 data.remove(currentPhoto); 802 if (currentPhoto >= data.size()) { 803 currentPhoto = data.size() - 1; 804 } 805 if (currentPhoto >= 0) { 806 ImageViewerDialog.showImage(this, data.get(currentPhoto)); 807 } else { 808 ImageViewerDialog.showImage(this, null); 809 } 810 811 if (toDelete.getFile().delete()) { 812 Main.info("File "+toDelete.getFile().toString()+" deleted. "); 813 } else { 814 JOptionPane.showMessageDialog( 815 Main.parent, 816 tr("Image file could not be deleted."), 817 tr("Error"), 818 JOptionPane.ERROR_MESSAGE 819 ); 820 } 821 822 updateOffscreenBuffer = true; 823 Main.map.repaint(); 824 } 825 } 826 } 827 828 public void copyCurrentPhotoPath() { 829 ImageEntry toCopy = null; 830 if (data != null && data.size() > 0 && currentPhoto >= 0 && currentPhoto < data.size()) { 831 toCopy = data.get(currentPhoto); 832 String copyString = toCopy.getFile().toString(); 833 Utils.copyToClipboard(copyString); 834 } 835 } 836 837 /** 838 * Removes a photo from the list of images by index. 839 * @param idx Image index 840 * @since 6392 841 */ 842 public void removePhotoByIdx(int idx) { 843 if (idx >= 0 && data != null && idx < data.size()) { 844 data.remove(idx); 845 } 846 } 847 848 /** 849 * Returns the image that matches the position of the mouse event. 850 * @param evt Mouse event 851 * @return Image at mouse position, or {@code null} if there is no image at the mouse position 852 * @since 6392 853 */ 854 public ImageEntry getPhotoUnderMouse(MouseEvent evt) { 855 if (data != null) { 856 for (int idx = data.size() - 1; idx >= 0; --idx) { 857 ImageEntry img = data.get(idx); 858 if (img.getPos() == null) { 859 continue; 860 } 861 Point p = Main.map.mapView.getPoint(img.getPos()); 862 Rectangle r; 863 if (useThumbs && img.thumbnail != null) { 864 Dimension d = scaledDimension(img.thumbnail); 865 r = new Rectangle(p.x - d.width / 2, p.y - d.height / 2, d.width, d.height); 866 } else { 867 r = new Rectangle(p.x - icon.getIconWidth() / 2, 868 p.y - icon.getIconHeight() / 2, 869 icon.getIconWidth(), 870 icon.getIconHeight()); 871 } 872 if (r.contains(evt.getPoint())) { 873 return img; 874 } 875 } 876 } 877 return null; 878 } 879 880 /** 881 * Clears the currentPhoto, i.e. remove select marker, and optionally repaint. 882 * @param repaint Repaint flag 883 * @since 6392 884 */ 885 public void clearCurrentPhoto(boolean repaint) { 886 currentPhoto = -1; 887 if (repaint) { 888 updateBufferAndRepaint(); 889 } 890 } 891 892 /** 893 * Clears the currentPhoto of the other GeoImageLayer's. Otherwise there could be multiple selected photos. 894 */ 895 private void clearOtherCurrentPhotos() { 896 for (GeoImageLayer layer: 897 Main.map.mapView.getLayersOfType(GeoImageLayer.class)) { 898 if (layer != this) { 899 layer.clearCurrentPhoto(false); 900 } 901 } 902 } 903 904 private static List<MapMode> supportedMapModes = null; 905 906 /** 907 * Registers a map mode for which the functionality of this layer should be available. 908 * @param mapMode Map mode to be registered 909 * @since 6392 910 */ 911 public static void registerSupportedMapMode(MapMode mapMode) { 912 if (supportedMapModes == null) { 913 supportedMapModes = new ArrayList<>(); 914 } 915 supportedMapModes.add(mapMode); 916 } 917 918 /** 919 * Determines if the functionality of this layer is available in 920 * the specified map mode. {@link SelectAction} and {@link LassoModeAction} are supported by default, 921 * other map modes can be registered. 922 * @param mapMode Map mode to be checked 923 * @return {@code true} if the map mode is supported, 924 * {@code false} otherwise 925 */ 926 private static final boolean isSupportedMapMode(MapMode mapMode) { 927 if (mapMode instanceof SelectAction || mapMode instanceof LassoModeAction) { 928 return true; 929 } 930 if (supportedMapModes != null) { 931 for (MapMode supmmode: supportedMapModes) { 932 if (mapMode == supmmode) { 933 return true; 934 } 935 } 936 } 937 return false; 938 } 939 940 private MouseAdapter mouseAdapter = null; 941 private MapModeChangeListener mapModeListener = null; 942 943 @Override 944 public void hookUpMapView() { 945 mouseAdapter = new MouseAdapter() { 946 private final boolean isMapModeOk() { 947 return Main.map.mapMode == null || isSupportedMapMode(Main.map.mapMode); 948 } 949 @Override public void mousePressed(MouseEvent e) { 950 951 if (e.getButton() != MouseEvent.BUTTON1) 952 return; 953 if (isVisible() && isMapModeOk()) { 954 Main.map.mapView.repaint(); 955 } 956 } 957 958 @Override public void mouseReleased(MouseEvent ev) { 959 if (ev.getButton() != MouseEvent.BUTTON1) 960 return; 961 if (data == null || !isVisible() || !isMapModeOk()) 962 return; 963 964 for (int i = data.size() - 1; i >= 0; --i) { 965 ImageEntry e = data.get(i); 966 if (e.getPos() == null) { 967 continue; 968 } 969 Point p = Main.map.mapView.getPoint(e.getPos()); 970 Rectangle r; 971 if (useThumbs && e.thumbnail != null) { 972 Dimension d = scaledDimension(e.thumbnail); 973 r = new Rectangle(p.x - d.width / 2, p.y - d.height / 2, d.width, d.height); 974 } else { 975 r = new Rectangle(p.x - icon.getIconWidth() / 2, 976 p.y - icon.getIconHeight() / 2, 977 icon.getIconWidth(), 978 icon.getIconHeight()); 979 } 980 if (r.contains(ev.getPoint())) { 981 clearOtherCurrentPhotos(); 982 currentPhoto = i; 983 ImageViewerDialog.showImage(GeoImageLayer.this, e); 984 Main.map.repaint(); 985 break; 986 } 987 } 988 } 989 }; 990 991 mapModeListener = new MapModeChangeListener() { 992 @Override 993 public void mapModeChange(MapMode oldMapMode, MapMode newMapMode) { 994 if (newMapMode == null || isSupportedMapMode(newMapMode)) { 995 Main.map.mapView.addMouseListener(mouseAdapter); 996 } else { 997 Main.map.mapView.removeMouseListener(mouseAdapter); 998 } 999 } 1000 }; 1001 1002 MapFrame.addMapModeChangeListener(mapModeListener); 1003 mapModeListener.mapModeChange(null, Main.map.mapMode); 1004 1005 MapView.addLayerChangeListener(new LayerChangeListener() { 1006 @Override 1007 public void activeLayerChange(Layer oldLayer, Layer newLayer) { 1008 if (newLayer == GeoImageLayer.this) { 1009 // only in select mode it is possible to click the images 1010 Main.map.selectSelectTool(false); 1011 } 1012 } 1013 1014 @Override 1015 public void layerAdded(Layer newLayer) { 1016 } 1017 1018 @Override 1019 public void layerRemoved(Layer oldLayer) { 1020 if (oldLayer == GeoImageLayer.this) { 1021 stopLoadThumbs(); 1022 Main.map.mapView.removeMouseListener(mouseAdapter); 1023 MapFrame.removeMapModeChangeListener(mapModeListener); 1024 currentPhoto = -1; 1025 data.clear(); 1026 data = null; 1027 // stop listening to layer change events 1028 MapView.removeLayerChangeListener(this); 1029 } 1030 } 1031 }); 1032 1033 Main.map.mapView.addPropertyChangeListener(this); 1034 if (Main.map.getToggleDialog(ImageViewerDialog.class) == null) { 1035 ImageViewerDialog.newInstance(); 1036 Main.map.addToggleDialog(ImageViewerDialog.getInstance()); 1037 } 1038 } 1039 1040 @Override 1041 public void propertyChange(PropertyChangeEvent evt) { 1042 if (NavigatableComponent.PROPNAME_CENTER.equals(evt.getPropertyName()) || NavigatableComponent.PROPNAME_SCALE.equals(evt.getPropertyName())) { 1043 updateOffscreenBuffer = true; 1044 } 1045 } 1046 1047 /** 1048 * Start to load thumbnails. 1049 */ 1050 public synchronized void startLoadThumbs() { 1051 if (useThumbs && !thumbsLoaded && !thumbsLoaderRunning) { 1052 stopLoadThumbs(); 1053 thumbsloader = new ThumbsLoader(this); 1054 thumbsLoaderExecutor.submit(thumbsloader); 1055 thumbsLoaderRunning = true; 1056 } 1057 } 1058 1059 /** 1060 * Stop to load thumbnails. 1061 * 1062 * Can be called at any time to make sure that the 1063 * thumbnail loader is stopped. 1064 */ 1065 public synchronized void stopLoadThumbs() { 1066 if (thumbsloader != null) { 1067 thumbsloader.stop = true; 1068 } 1069 thumbsLoaderRunning = false; 1070 } 1071 1072 /** 1073 * Called to signal that the loading of thumbnails has finished. 1074 * 1075 * Usually called from {@link ThumbsLoader} in another thread. 1076 */ 1077 public void thumbsLoaded() { 1078 thumbsLoaded = true; 1079 } 1080 1081 public void updateBufferAndRepaint() { 1082 updateOffscreenBuffer = true; 1083 Main.map.mapView.repaint(); 1084 } 1085 1086 /** 1087 * Get list of images in layer. 1088 * @return List of images in layer 1089 */ 1090 public List<ImageEntry> getImages() { 1091 List<ImageEntry> copy = new ArrayList<>(data.size()); 1092 for (ImageEntry ie : data) { 1093 copy.add(ie); 1094 } 1095 return copy; 1096 } 1097 1098 /** 1099 * Returns the associated GPX layer. 1100 * @return The associated GPX layer 1101 */ 1102 public GpxLayer getGpxLayer() { 1103 return gpxLayer; 1104 } 1105 1106 @Override 1107 public void jumpToNextMarker() { 1108 showNextPhoto(); 1109 } 1110 1111 @Override 1112 public void jumpToPreviousMarker() { 1113 showPreviousPhoto(); 1114 } 1115 1116 /** 1117 * Returns the current thumbnail display status. 1118 * {@code true}: thumbnails are displayed, {@code false}: an icon is displayed instead of thumbnails. 1119 * @return Current thumbnail display status 1120 * @since 6392 1121 */ 1122 public boolean isUseThumbs() { 1123 return useThumbs; 1124 } 1125 1126 /** 1127 * Enables or disables the display of thumbnails. Does not update the display. 1128 * @param useThumbs New thumbnail display status 1129 * @since 6392 1130 */ 1131 public void setUseThumbs(boolean useThumbs) { 1132 this.useThumbs = useThumbs; 1133 if (useThumbs && !thumbsLoaded) { 1134 startLoadThumbs(); 1135 } else if (!useThumbs) { 1136 stopLoadThumbs(); 1137 } 1138 } 1139}