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