source: josm/trunk/src/org/openstreetmap/josm/gui/layer/geoimage/GeoImageLayer.java@ 8818

Last change on this file since 8818 was 8818, checked in by simon04, 9 years ago

fix #11925, see #11897 - Re-enable merging of gpx, marker, geoimage layers

Regression of r8728.

  • Property svn:eol-style set to native
File size: 40.2 KB
Line 
1// License: GPL. For details, see LICENSE file.
2package org.openstreetmap.josm.gui.layer.geoimage;
3
4import static org.openstreetmap.josm.tools.I18n.tr;
5import static org.openstreetmap.josm.tools.I18n.trn;
6
7import java.awt.AlphaComposite;
8import java.awt.BasicStroke;
9import java.awt.Color;
10import java.awt.Composite;
11import java.awt.Dimension;
12import java.awt.Graphics2D;
13import java.awt.Image;
14import java.awt.Point;
15import java.awt.Rectangle;
16import java.awt.RenderingHints;
17import java.awt.event.MouseAdapter;
18import java.awt.event.MouseEvent;
19import java.awt.image.BufferedImage;
20import java.beans.PropertyChangeEvent;
21import java.beans.PropertyChangeListener;
22import java.io.File;
23import java.io.IOException;
24import java.text.ParseException;
25import java.util.ArrayList;
26import java.util.Arrays;
27import java.util.Calendar;
28import java.util.Collection;
29import java.util.Collections;
30import java.util.GregorianCalendar;
31import java.util.HashSet;
32import java.util.LinkedHashSet;
33import java.util.LinkedList;
34import java.util.List;
35import java.util.Set;
36import java.util.TimeZone;
37import java.util.concurrent.ExecutorService;
38import java.util.concurrent.Executors;
39
40import javax.swing.Action;
41import javax.swing.Icon;
42import javax.swing.JLabel;
43import javax.swing.JOptionPane;
44import javax.swing.SwingConstants;
45
46import org.openstreetmap.josm.Main;
47import org.openstreetmap.josm.actions.LassoModeAction;
48import org.openstreetmap.josm.actions.RenameLayerAction;
49import org.openstreetmap.josm.actions.mapmode.MapMode;
50import org.openstreetmap.josm.actions.mapmode.SelectAction;
51import org.openstreetmap.josm.data.Bounds;
52import org.openstreetmap.josm.data.coor.LatLon;
53import org.openstreetmap.josm.data.osm.visitor.BoundingXYVisitor;
54import org.openstreetmap.josm.gui.ExtendedDialog;
55import org.openstreetmap.josm.gui.MapFrame;
56import org.openstreetmap.josm.gui.MapFrame.MapModeChangeListener;
57import org.openstreetmap.josm.gui.MapView;
58import org.openstreetmap.josm.gui.MapView.LayerChangeListener;
59import org.openstreetmap.josm.gui.NavigatableComponent;
60import org.openstreetmap.josm.gui.PleaseWaitRunnable;
61import org.openstreetmap.josm.gui.dialogs.LayerListDialog;
62import org.openstreetmap.josm.gui.dialogs.LayerListPopup;
63import org.openstreetmap.josm.gui.layer.GpxLayer;
64import org.openstreetmap.josm.gui.layer.JumpToMarkerActions.JumpToMarkerLayer;
65import org.openstreetmap.josm.gui.layer.JumpToMarkerActions.JumpToNextMarker;
66import org.openstreetmap.josm.gui.layer.JumpToMarkerActions.JumpToPreviousMarker;
67import org.openstreetmap.josm.gui.layer.Layer;
68import org.openstreetmap.josm.gui.util.GuiHelper;
69import org.openstreetmap.josm.io.JpgImporter;
70import org.openstreetmap.josm.tools.ExifReader;
71import org.openstreetmap.josm.tools.ImageProvider;
72import org.openstreetmap.josm.tools.Utils;
73
74import com.drew.imaging.jpeg.JpegMetadataReader;
75import com.drew.lang.CompoundException;
76import com.drew.metadata.Directory;
77import com.drew.metadata.Metadata;
78import com.drew.metadata.MetadataException;
79import com.drew.metadata.exif.ExifIFD0Directory;
80import com.drew.metadata.exif.GpsDirectory;
81
82/**
83 * Layer displaying geottaged pictures.
84 */
85public class GeoImageLayer extends Layer implements PropertyChangeListener, JumpToMarkerLayer {
86
87 List<ImageEntry> data;
88 GpxLayer gpxLayer;
89
90 private Icon icon = ImageProvider.get("dialogs/geoimage/photo-marker");
91 private Icon selectedIcon = ImageProvider.get("dialogs/geoimage/photo-marker-selected");
92
93 private int currentPhoto = -1;
94
95 boolean useThumbs = false;
96 private ExecutorService thumbsLoaderExecutor =
97 Executors.newSingleThreadExecutor(Utils.newThreadFactory("thumbnail-loader-%d", Thread.MIN_PRIORITY));
98 private ThumbsLoader thumbsloader;
99 private boolean thumbsLoaderRunning = false;
100 volatile boolean thumbsLoaded = false;
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 = false;
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 public 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 protected void realRun() throws IOException {
130
131 progressMonitor.subTask(tr("Starting directory scan"));
132 Collection<File> files = new ArrayList<>();
133 try {
134 addRecursiveFiles(files, selection);
135 } catch (IllegalStateException e) {
136 rememberError(e.getMessage());
137 }
138
139 if (canceled)
140 return;
141 progressMonitor.subTask(tr("Read photos..."));
142 progressMonitor.setTicksCount(files.size());
143
144 progressMonitor.subTask(tr("Read photos..."));
145 progressMonitor.setTicksCount(files.size());
146
147 // read the image files
148 List<ImageEntry> data = new ArrayList<>(files.size());
149
150 for (File f : files) {
151
152 if (canceled) {
153 break;
154 }
155
156 progressMonitor.subTask(tr("Reading {0}...", f.getName()));
157 progressMonitor.worked(1);
158
159 ImageEntry e = new ImageEntry();
160
161 // Changed to silently cope with no time info in exif. One case
162 // of person having time that couldn't be parsed, but valid GPS info
163
164 try {
165 e.setExifTime(ExifReader.readTime(f));
166 } catch (ParseException ex) {
167 e.setExifTime(null);
168 }
169 e.setFile(f);
170 extractExif(e);
171 data.add(e);
172 }
173 layer = new GeoImageLayer(data, gpxLayer);
174 files.clear();
175 }
176
177 private void addRecursiveFiles(Collection<File> files, Collection<File> sel) {
178 boolean nullFile = false;
179
180 for (File f : sel) {
181
182 if (canceled) {
183 break;
184 }
185
186 if (f == null) {
187 nullFile = true;
188
189 } else if (f.isDirectory()) {
190 String canonical = null;
191 try {
192 canonical = f.getCanonicalPath();
193 } catch (IOException e) {
194 Main.error(e);
195 rememberError(tr("Unable to get canonical path for directory {0}\n",
196 f.getAbsolutePath()));
197 }
198
199 if (canonical == null || loadedDirectories.contains(canonical)) {
200 continue;
201 } else {
202 loadedDirectories.add(canonical);
203 }
204
205 File[] children = f.listFiles(JpgImporter.FILE_FILTER_WITH_FOLDERS);
206 if (children != null) {
207 progressMonitor.subTask(tr("Scanning directory {0}", f.getPath()));
208 addRecursiveFiles(files, Arrays.asList(children));
209 } else {
210 rememberError(tr("Error while getting files from directory {0}\n", f.getPath()));
211 }
212
213 } else {
214 files.add(f);
215 }
216 }
217
218 if (nullFile) {
219 throw new IllegalStateException(tr("One of the selected files was null"));
220 }
221 }
222
223 protected String formatErrorMessages() {
224 StringBuilder sb = new StringBuilder();
225 sb.append("<html>");
226 if (errorMessages.size() == 1) {
227 sb.append(errorMessages.iterator().next());
228 } else {
229 sb.append(Utils.joinAsHtmlUnorderedList(errorMessages));
230 }
231 sb.append("</html>");
232 return sb.toString();
233 }
234
235 @Override protected void finish() {
236 if (!errorMessages.isEmpty()) {
237 JOptionPane.showMessageDialog(
238 Main.parent,
239 formatErrorMessages(),
240 tr("Error"),
241 JOptionPane.ERROR_MESSAGE
242 );
243 }
244 if (layer != null) {
245 Main.main.addLayer(layer);
246
247 if (!canceled && layer.data != null && !layer.data.isEmpty()) {
248 boolean noGeotagFound = true;
249 for (ImageEntry e : layer.data) {
250 if (e.getPos() != null) {
251 noGeotagFound = false;
252 }
253 }
254 if (noGeotagFound) {
255 new CorrelateGpxWithImages(layer).actionPerformed(null);
256 }
257 }
258 }
259 }
260
261 @Override protected void cancel() {
262 canceled = true;
263 }
264 }
265
266 public static void create(Collection<File> files, GpxLayer gpxLayer) {
267 Loader loader = new Loader(files, gpxLayer);
268 Main.worker.execute(loader);
269 }
270
271 /**
272 * Constructs a new {@code GeoImageLayer}.
273 * @param data The list of images to display
274 * @param gpxLayer The associated GPX layer
275 */
276 public GeoImageLayer(final List<ImageEntry> data, GpxLayer gpxLayer) {
277 this(data, gpxLayer, null, false);
278 }
279
280 /**
281 * Constructs a new {@code GeoImageLayer}.
282 * @param data The list of images to display
283 * @param gpxLayer The associated GPX layer
284 * @param name Layer name
285 * @since 6392
286 */
287 public GeoImageLayer(final List<ImageEntry> data, GpxLayer gpxLayer, final String name) {
288 this(data, gpxLayer, name, false);
289 }
290
291 /**
292 * Constructs a new {@code GeoImageLayer}.
293 * @param data The list of images to display
294 * @param gpxLayer The associated GPX layer
295 * @param useThumbs Thumbnail display flag
296 * @since 6392
297 */
298 public GeoImageLayer(final List<ImageEntry> data, GpxLayer gpxLayer, boolean useThumbs) {
299 this(data, gpxLayer, null, useThumbs);
300 }
301
302 /**
303 * Constructs a new {@code GeoImageLayer}.
304 * @param data The list of images to display
305 * @param gpxLayer The associated GPX layer
306 * @param name Layer name
307 * @param useThumbs Thumbnail display flag
308 * @since 6392
309 */
310 public GeoImageLayer(final List<ImageEntry> data, GpxLayer gpxLayer, final String name, boolean useThumbs) {
311 super(name != null ? name : tr("Geotagged Images"));
312 if (data != null) {
313 Collections.sort(data);
314 }
315 this.data = data;
316 this.gpxLayer = gpxLayer;
317 this.useThumbs = useThumbs;
318 }
319
320 @Override
321 public Icon getIcon() {
322 return ImageProvider.get("dialogs/geoimage");
323 }
324
325 private static List<Action> menuAdditions = new LinkedList<>();
326
327 public static void registerMenuAddition(Action addition) {
328 menuAdditions.add(addition);
329 }
330
331 @Override
332 public Action[] getMenuEntries() {
333
334 List<Action> entries = new ArrayList<>();
335 entries.add(LayerListDialog.getInstance().createShowHideLayerAction());
336 entries.add(LayerListDialog.getInstance().createDeleteLayerAction());
337 entries.add(LayerListDialog.getInstance().createMergeLayerAction(this));
338 entries.add(new RenameLayerAction(null, this));
339 entries.add(SeparatorLayerAction.INSTANCE);
340 entries.add(new CorrelateGpxWithImages(this));
341 entries.add(new ShowThumbnailAction(this));
342 if (!menuAdditions.isEmpty()) {
343 entries.add(SeparatorLayerAction.INSTANCE);
344 entries.addAll(menuAdditions);
345 }
346 entries.add(SeparatorLayerAction.INSTANCE);
347 entries.add(new JumpToNextMarker(this));
348 entries.add(new JumpToPreviousMarker(this));
349 entries.add(SeparatorLayerAction.INSTANCE);
350 entries.add(new LayerListPopup.InfoAction(this));
351
352 return entries.toArray(new Action[entries.size()]);
353
354 }
355
356 /**
357 * Prepare the string that is displayed if layer information is requested.
358 * @return String with layer information
359 */
360 private String infoText() {
361 int tagged = 0;
362 int newdata = 0;
363 int n = 0;
364 if (data != null) {
365 n = data.size();
366 for (ImageEntry e : data) {
367 if (e.getPos() != null) {
368 tagged++;
369 }
370 if (e.hasNewGpsData()) {
371 newdata++;
372 }
373 }
374 }
375 return "<html>"
376 + trn("{0} image loaded.", "{0} images loaded.", n, n)
377 + " " + trn("{0} was found to be GPS tagged.", "{0} were found to be GPS tagged.", tagged, tagged)
378 + (newdata > 0 ? "<br>" + trn("{0} has updated GPS data.", "{0} have updated GPS data.", newdata, newdata) : "")
379 + "</html>";
380 }
381
382 @Override public Object getInfoComponent() {
383 return infoText();
384 }
385
386 @Override
387 public String getToolTipText() {
388 return infoText();
389 }
390
391 @Override
392 public boolean isMergable(Layer other) {
393 return other instanceof GeoImageLayer;
394 }
395
396 @Override
397 public void mergeFrom(Layer from) {
398 GeoImageLayer l = (GeoImageLayer) from;
399
400 // Stop to load thumbnails on both layers. Thumbnail loading will continue the next time
401 // the layer is painted.
402 stopLoadThumbs();
403 l.stopLoadThumbs();
404
405 final ImageEntry selected = l.data != null && l.currentPhoto >= 0 ? l.data.get(l.currentPhoto) : null;
406
407 if (l.data != null) {
408 data.addAll(l.data);
409 }
410 Collections.sort(data);
411
412 // Supress the double photos.
413 if (data.size() > 1) {
414 ImageEntry cur;
415 ImageEntry prev = data.get(data.size() - 1);
416 for (int i = data.size() - 2; i >= 0; i--) {
417 cur = data.get(i);
418 if (cur.getFile().equals(prev.getFile())) {
419 data.remove(i);
420 } else {
421 prev = cur;
422 }
423 }
424 }
425
426 if (selected != null && !data.isEmpty()) {
427 GuiHelper.runInEDTAndWait(new Runnable() {
428 @Override
429 public void run() {
430 for (int i = 0; i < data.size(); i++) {
431 if (selected.equals(data.get(i))) {
432 currentPhoto = i;
433 ImageViewerDialog.showImage(GeoImageLayer.this, data.get(i));
434 break;
435 }
436 }
437 }
438 });
439 }
440
441 setName(l.getName());
442 thumbsLoaded &= l.thumbsLoaded;
443 }
444
445 private Dimension scaledDimension(Image thumb) {
446 final double d = Main.map.mapView.getDist100Pixel();
447 final double size = 10 /*meter*/; /* size of the photo on the map */
448 double s = size * 100 /*px*/ / d;
449
450 final double sMin = ThumbsLoader.minSize;
451 final double sMax = ThumbsLoader.maxSize;
452
453 if (s < sMin) {
454 s = sMin;
455 }
456 if (s > sMax) {
457 s = sMax;
458 }
459 final double f = s / sMax; /* scale factor */
460
461 if (thumb == null)
462 return null;
463
464 return new Dimension(
465 (int) Math.round(f * thumb.getWidth(null)),
466 (int) Math.round(f * thumb.getHeight(null)));
467 }
468
469 @Override
470 public void paint(Graphics2D g, MapView mv, Bounds bounds) {
471 int width = mv.getWidth();
472 int height = mv.getHeight();
473 Rectangle clip = g.getClipBounds();
474 if (useThumbs) {
475 if (!thumbsLoaded) {
476 startLoadThumbs();
477 }
478
479 if (null == offscreenBuffer || offscreenBuffer.getWidth() != width // reuse the old buffer if possible
480 || offscreenBuffer.getHeight() != height) {
481 offscreenBuffer = new BufferedImage(width, height,
482 BufferedImage.TYPE_INT_ARGB);
483 updateOffscreenBuffer = true;
484 }
485
486 if (updateOffscreenBuffer) {
487 Graphics2D tempG = offscreenBuffer.createGraphics();
488 tempG.setColor(new Color(0, 0, 0, 0));
489 Composite saveComp = tempG.getComposite();
490 tempG.setComposite(AlphaComposite.Clear); // remove the old images
491 tempG.fillRect(0, 0, width, height);
492 tempG.setComposite(saveComp);
493
494 if (data != null) {
495 for (ImageEntry e : data) {
496 if (e.getPos() == null) {
497 continue;
498 }
499 Point p = mv.getPoint(e.getPos());
500 if (e.thumbnail != null) {
501 Dimension d = scaledDimension(e.thumbnail);
502 Rectangle target = new Rectangle(p.x - d.width / 2, p.y - d.height / 2, d.width, d.height);
503 if (clip.intersects(target)) {
504 tempG.drawImage(e.thumbnail, target.x, target.y, target.width, target.height, null);
505 }
506 } else { // thumbnail not loaded yet
507 icon.paintIcon(mv, tempG,
508 p.x - icon.getIconWidth() / 2,
509 p.y - icon.getIconHeight() / 2);
510 }
511 }
512 }
513 updateOffscreenBuffer = false;
514 }
515 g.drawImage(offscreenBuffer, 0, 0, null);
516 } else if (data != null) {
517 for (ImageEntry e : data) {
518 if (e.getPos() == null) {
519 continue;
520 }
521 Point p = mv.getPoint(e.getPos());
522 icon.paintIcon(mv, g,
523 p.x - icon.getIconWidth() / 2,
524 p.y - icon.getIconHeight() / 2);
525 }
526 }
527
528 if (currentPhoto >= 0 && currentPhoto < data.size()) {
529 ImageEntry e = data.get(currentPhoto);
530
531 if (e.getPos() != null) {
532 Point p = mv.getPoint(e.getPos());
533
534 int imgWidth = 100;
535 int imgHeight = 100;
536 if (useThumbs && e.thumbnail != null) {
537 Dimension d = scaledDimension(e.thumbnail);
538 imgWidth = d.width;
539 imgHeight = d.height;
540 } else {
541 imgWidth = selectedIcon.getIconWidth();
542 imgHeight = selectedIcon.getIconHeight();
543 }
544
545 if (e.getExifImgDir() != null) {
546 // Multiplier must be larger than sqrt(2)/2=0.71.
547 double arrowlength = Math.max(25, Math.max(imgWidth, imgHeight) * 0.85);
548 double arrowwidth = arrowlength / 1.4;
549
550 double dir = e.getExifImgDir();
551 // Rotate 90 degrees CCW
552 double headdir = (dir < 90) ? dir + 270 : dir - 90;
553 double leftdir = (headdir < 90) ? headdir + 270 : headdir - 90;
554 double rightdir = (headdir > 270) ? headdir - 270 : headdir + 90;
555
556 double ptx = p.x + Math.cos(Math.toRadians(headdir)) * arrowlength;
557 double pty = p.y + Math.sin(Math.toRadians(headdir)) * arrowlength;
558
559 double ltx = p.x + Math.cos(Math.toRadians(leftdir)) * arrowwidth/2;
560 double lty = p.y + Math.sin(Math.toRadians(leftdir)) * arrowwidth/2;
561
562 double rtx = p.x + Math.cos(Math.toRadians(rightdir)) * arrowwidth/2;
563 double rty = p.y + Math.sin(Math.toRadians(rightdir)) * arrowwidth/2;
564
565 g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
566 g.setColor(new Color(255, 255, 255, 192));
567 int[] xar = {(int) ltx, (int) ptx, (int) rtx, (int) ltx};
568 int[] yar = {(int) lty, (int) pty, (int) rty, (int) lty};
569 g.fillPolygon(xar, yar, 4);
570 g.setColor(Color.black);
571 g.setStroke(new BasicStroke(1.2f));
572 g.drawPolyline(xar, yar, 3);
573 }
574
575 if (useThumbs && e.thumbnail != null) {
576 g.setColor(new Color(128, 0, 0, 122));
577 g.fillRect(p.x - imgWidth / 2, p.y - imgHeight / 2, imgWidth, imgHeight);
578 } else {
579 selectedIcon.paintIcon(mv, g,
580 p.x - imgWidth / 2,
581 p.y - imgHeight / 2);
582
583 }
584 }
585 }
586 }
587
588 @Override
589 public void visitBoundingBox(BoundingXYVisitor v) {
590 for (ImageEntry e : data) {
591 v.visit(e.getPos());
592 }
593 }
594
595 /**
596 * Extract GPS metadata from image EXIF
597 *
598 * If successful, fills in the LatLon and EastNorth attributes of passed in image
599 */
600 private static void extractExif(ImageEntry e) {
601
602 Metadata metadata;
603 Directory dirExif;
604 GpsDirectory dirGps;
605
606 try {
607 metadata = JpegMetadataReader.readMetadata(e.getFile());
608 dirExif = metadata.getFirstDirectoryOfType(ExifIFD0Directory.class);
609 dirGps = metadata.getFirstDirectoryOfType(GpsDirectory.class);
610 } catch (CompoundException | IOException p) {
611 e.setExifCoor(null);
612 e.setPos(null);
613 return;
614 }
615
616 try {
617 if (dirExif != null) {
618 int orientation = dirExif.getInt(ExifIFD0Directory.TAG_ORIENTATION);
619 e.setExifOrientation(orientation);
620 }
621 } catch (MetadataException ex) {
622 Main.debug(ex.getMessage());
623 }
624
625 if (dirGps == null) {
626 e.setExifCoor(null);
627 e.setPos(null);
628 return;
629 }
630
631 try {
632 double speed = dirGps.getDouble(GpsDirectory.TAG_SPEED);
633 String speedRef = dirGps.getString(GpsDirectory.TAG_SPEED_REF);
634 if ("M".equalsIgnoreCase(speedRef)) {
635 // miles per hour
636 speed *= 1.609344;
637 } else if ("N".equalsIgnoreCase(speedRef)) {
638 // knots == nautical miles per hour
639 speed *= 1.852;
640 }
641 // default is K (km/h)
642 e.setSpeed(speed);
643 } catch (Exception ex) {
644 Main.debug(ex.getMessage());
645 }
646
647 try {
648 double ele = dirGps.getDouble(GpsDirectory.TAG_ALTITUDE);
649 int d = dirGps.getInt(GpsDirectory.TAG_ALTITUDE_REF);
650 if (d == 1) {
651 ele *= -1;
652 }
653 e.setElevation(ele);
654 } catch (MetadataException ex) {
655 Main.debug(ex.getMessage());
656 }
657
658 try {
659 LatLon latlon = ExifReader.readLatLon(dirGps);
660 e.setExifCoor(latlon);
661 e.setPos(e.getExifCoor());
662
663 } catch (Exception ex) { // (other exceptions, e.g. #5271)
664 Main.error("Error reading EXIF from file: "+ex);
665 e.setExifCoor(null);
666 e.setPos(null);
667 }
668
669 try {
670 Double direction = ExifReader.readDirection(dirGps);
671 if (direction != null) {
672 e.setExifImgDir(direction.doubleValue());
673 }
674 } catch (Exception ex) { // (CompoundException and other exceptions, e.g. #5271)
675 Main.debug(ex.getMessage());
676 }
677
678 // Time and date. We can have these cases:
679 // 1) GPS_TIME_STAMP not set -> date/time will be null
680 // 2) GPS_DATE_STAMP not set -> use EXIF date or set to default
681 // 3) GPS_TIME_STAMP and GPS_DATE_STAMP are set
682 int[] timeStampComps = dirGps.getIntArray(GpsDirectory.TAG_TIME_STAMP);
683 if (timeStampComps != null) {
684 int gpsHour = timeStampComps[0];
685 int gpsMin = timeStampComps[1];
686 int gpsSec = timeStampComps[2];
687 Calendar cal = new GregorianCalendar(TimeZone.getTimeZone("UTC"));
688
689 // We have the time. Next step is to check if the GPS date stamp is set.
690 // dirGps.getString() always succeeds, but the return value might be null.
691 String dateStampStr = dirGps.getString(GpsDirectory.TAG_DATE_STAMP);
692 if (dateStampStr != null && dateStampStr.matches("^\\d+:\\d+:\\d+$")) {
693 String[] dateStampComps = dateStampStr.split(":");
694 cal.set(Calendar.YEAR, Integer.parseInt(dateStampComps[0]));
695 cal.set(Calendar.MONTH, Integer.parseInt(dateStampComps[1]) - 1);
696 cal.set(Calendar.DAY_OF_MONTH, Integer.parseInt(dateStampComps[2]));
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.isEmpty()) {
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.isEmpty()) {
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.isEmpty()) {
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(data != null && currentPhoto < data.size() - 1);
762 ImageViewerDialog.setPreviousEnabled(currentPhoto > 0);
763 }
764
765 public void removeCurrentPhoto() {
766 if (data != null && !data.isEmpty() && 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.isEmpty() && 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 data.remove(currentPhoto);
801 if (currentPhoto >= data.size()) {
802 currentPhoto = data.size() - 1;
803 }
804 if (currentPhoto >= 0) {
805 ImageViewerDialog.showImage(this, data.get(currentPhoto));
806 } else {
807 ImageViewerDialog.showImage(this, null);
808 }
809
810 if (toDelete.getFile().delete()) {
811 Main.info("File "+toDelete.getFile()+" deleted. ");
812 } else {
813 JOptionPane.showMessageDialog(
814 Main.parent,
815 tr("Image file could not be deleted."),
816 tr("Error"),
817 JOptionPane.ERROR_MESSAGE
818 );
819 }
820
821 updateOffscreenBuffer = true;
822 Main.map.repaint();
823 }
824 }
825 }
826
827 public void copyCurrentPhotoPath() {
828 ImageEntry toCopy = null;
829 if (data != null && !data.isEmpty() && currentPhoto >= 0 && currentPhoto < data.size()) {
830 toCopy = data.get(currentPhoto);
831 String copyString = toCopy.getFile().toString();
832 Utils.copyToClipboard(copyString);
833 }
834 }
835
836 /**
837 * Removes a photo from the list of images by index.
838 * @param idx Image index
839 * @since 6392
840 */
841 public void removePhotoByIdx(int idx) {
842 if (idx >= 0 && data != null && idx < data.size()) {
843 data.remove(idx);
844 }
845 }
846
847 /**
848 * Returns the image that matches the position of the mouse event.
849 * @param evt Mouse event
850 * @return Image at mouse position, or {@code null} if there is no image at the mouse position
851 * @since 6392
852 */
853 public ImageEntry getPhotoUnderMouse(MouseEvent evt) {
854 if (data != null) {
855 for (int idx = data.size() - 1; idx >= 0; --idx) {
856 ImageEntry img = data.get(idx);
857 if (img.getPos() == null) {
858 continue;
859 }
860 Point p = Main.map.mapView.getPoint(img.getPos());
861 Rectangle r;
862 if (useThumbs && img.thumbnail != null) {
863 Dimension d = scaledDimension(img.thumbnail);
864 r = new Rectangle(p.x - d.width / 2, p.y - d.height / 2, d.width, d.height);
865 } else {
866 r = new Rectangle(p.x - icon.getIconWidth() / 2,
867 p.y - icon.getIconHeight() / 2,
868 icon.getIconWidth(),
869 icon.getIconHeight());
870 }
871 if (r.contains(evt.getPoint())) {
872 return img;
873 }
874 }
875 }
876 return null;
877 }
878
879 /**
880 * Clears the currentPhoto, i.e. remove select marker, and optionally repaint.
881 * @param repaint Repaint flag
882 * @since 6392
883 */
884 public void clearCurrentPhoto(boolean repaint) {
885 currentPhoto = -1;
886 if (repaint) {
887 updateBufferAndRepaint();
888 }
889 }
890
891 /**
892 * Clears the currentPhoto of the other GeoImageLayer's. Otherwise there could be multiple selected photos.
893 */
894 private void clearOtherCurrentPhotos() {
895 for (GeoImageLayer layer:
896 Main.map.mapView.getLayersOfType(GeoImageLayer.class)) {
897 if (layer != this) {
898 layer.clearCurrentPhoto(false);
899 }
900 }
901 }
902
903 private static volatile List<MapMode> supportedMapModes = null;
904
905 /**
906 * Registers a map mode for which the functionality of this layer should be available.
907 * @param mapMode Map mode to be registered
908 * @since 6392
909 */
910 public static void registerSupportedMapMode(MapMode mapMode) {
911 if (supportedMapModes == null) {
912 supportedMapModes = new ArrayList<>();
913 }
914 supportedMapModes.add(mapMode);
915 }
916
917 /**
918 * Determines if the functionality of this layer is available in
919 * the specified map mode. {@link SelectAction} and {@link LassoModeAction} are supported by default,
920 * other map modes can be registered.
921 * @param mapMode Map mode to be checked
922 * @return {@code true} if the map mode is supported,
923 * {@code false} otherwise
924 */
925 private static boolean isSupportedMapMode(MapMode mapMode) {
926 if (mapMode instanceof SelectAction || mapMode instanceof LassoModeAction) {
927 return true;
928 }
929 if (supportedMapModes != null) {
930 for (MapMode supmmode: supportedMapModes) {
931 if (mapMode == supmmode) {
932 return true;
933 }
934 }
935 }
936 return false;
937 }
938
939 private MouseAdapter mouseAdapter = null;
940 private MapModeChangeListener mapModeListener = null;
941
942 @Override
943 public void hookUpMapView() {
944 mouseAdapter = new MouseAdapter() {
945 private boolean isMapModeOk() {
946 return Main.map.mapMode == null || isSupportedMapMode(Main.map.mapMode);
947 }
948
949 @Override
950 public void mousePressed(MouseEvent e) {
951 if (e.getButton() != MouseEvent.BUTTON1)
952 return;
953 if (isVisible() && isMapModeOk()) {
954 Main.map.mapView.repaint();
955 }
956 }
957
958 @Override
959 public void mouseReleased(MouseEvent ev) {
960 if (ev.getButton() != MouseEvent.BUTTON1)
961 return;
962 if (data == null || !isVisible() || !isMapModeOk())
963 return;
964
965 for (int i = data.size() - 1; i >= 0; --i) {
966 ImageEntry e = data.get(i);
967 if (e.getPos() == null) {
968 continue;
969 }
970 Point p = Main.map.mapView.getPoint(e.getPos());
971 Rectangle r;
972 if (useThumbs && e.thumbnail != null) {
973 Dimension d = scaledDimension(e.thumbnail);
974 r = new Rectangle(p.x - d.width / 2, p.y - d.height / 2, d.width, d.height);
975 } else {
976 r = new Rectangle(p.x - icon.getIconWidth() / 2,
977 p.y - icon.getIconHeight() / 2,
978 icon.getIconWidth(),
979 icon.getIconHeight());
980 }
981 if (r.contains(ev.getPoint())) {
982 clearOtherCurrentPhotos();
983 currentPhoto = i;
984 ImageViewerDialog.showImage(GeoImageLayer.this, e);
985 Main.map.repaint();
986 break;
987 }
988 }
989 }
990 };
991
992 mapModeListener = new MapModeChangeListener() {
993 @Override
994 public void mapModeChange(MapMode oldMapMode, MapMode newMapMode) {
995 if (newMapMode == null || isSupportedMapMode(newMapMode)) {
996 Main.map.mapView.addMouseListener(mouseAdapter);
997 } else {
998 Main.map.mapView.removeMouseListener(mouseAdapter);
999 }
1000 }
1001 };
1002
1003 MapFrame.addMapModeChangeListener(mapModeListener);
1004 mapModeListener.mapModeChange(null, Main.map.mapMode);
1005
1006 MapView.addLayerChangeListener(new LayerChangeListener() {
1007 @Override
1008 public void activeLayerChange(Layer oldLayer, Layer newLayer) {
1009 if (newLayer == GeoImageLayer.this) {
1010 // only in select mode it is possible to click the images
1011 Main.map.selectSelectTool(false);
1012 }
1013 }
1014
1015 @Override
1016 public void layerAdded(Layer newLayer) {
1017 }
1018
1019 @Override
1020 public void layerRemoved(Layer oldLayer) {
1021 if (oldLayer == GeoImageLayer.this) {
1022 stopLoadThumbs();
1023 Main.map.mapView.removeMouseListener(mouseAdapter);
1024 MapFrame.removeMapModeChangeListener(mapModeListener);
1025 currentPhoto = -1;
1026 if (data != null) {
1027 data.clear();
1028 }
1029 data = null;
1030 // stop listening to layer change events
1031 MapView.removeLayerChangeListener(this);
1032 }
1033 }
1034 });
1035
1036 Main.map.mapView.addPropertyChangeListener(this);
1037 if (Main.map.getToggleDialog(ImageViewerDialog.class) == null) {
1038 ImageViewerDialog.newInstance();
1039 Main.map.addToggleDialog(ImageViewerDialog.getInstance());
1040 }
1041 }
1042
1043 @Override
1044 public void propertyChange(PropertyChangeEvent evt) {
1045 if (NavigatableComponent.PROPNAME_CENTER.equals(evt.getPropertyName()) ||
1046 NavigatableComponent.PROPNAME_SCALE.equals(evt.getPropertyName())) {
1047 updateOffscreenBuffer = true;
1048 }
1049 }
1050
1051 /**
1052 * Start to load thumbnails.
1053 */
1054 public synchronized void startLoadThumbs() {
1055 if (useThumbs && !thumbsLoaded && !thumbsLoaderRunning) {
1056 stopLoadThumbs();
1057 thumbsloader = new ThumbsLoader(this);
1058 thumbsLoaderExecutor.submit(thumbsloader);
1059 thumbsLoaderRunning = true;
1060 }
1061 }
1062
1063 /**
1064 * Stop to load thumbnails.
1065 *
1066 * Can be called at any time to make sure that the
1067 * thumbnail loader is stopped.
1068 */
1069 public synchronized void stopLoadThumbs() {
1070 if (thumbsloader != null) {
1071 thumbsloader.stop = true;
1072 }
1073 thumbsLoaderRunning = false;
1074 }
1075
1076 /**
1077 * Called to signal that the loading of thumbnails has finished.
1078 *
1079 * Usually called from {@link ThumbsLoader} in another thread.
1080 */
1081 public void thumbsLoaded() {
1082 thumbsLoaded = true;
1083 }
1084
1085 public void updateBufferAndRepaint() {
1086 updateOffscreenBuffer = true;
1087 Main.map.mapView.repaint();
1088 }
1089
1090 /**
1091 * Get list of images in layer.
1092 * @return List of images in layer
1093 */
1094 public List<ImageEntry> getImages() {
1095 if (data == null) {
1096 return Collections.emptyList();
1097 }
1098 List<ImageEntry> copy = new ArrayList<>(data.size());
1099 for (ImageEntry ie : data) {
1100 copy.add(ie);
1101 }
1102 return copy;
1103 }
1104
1105 /**
1106 * Returns the associated GPX layer.
1107 * @return The associated GPX layer
1108 */
1109 public GpxLayer getGpxLayer() {
1110 return gpxLayer;
1111 }
1112
1113 @Override
1114 public void jumpToNextMarker() {
1115 showNextPhoto();
1116 }
1117
1118 @Override
1119 public void jumpToPreviousMarker() {
1120 showPreviousPhoto();
1121 }
1122
1123 /**
1124 * Returns the current thumbnail display status.
1125 * {@code true}: thumbnails are displayed, {@code false}: an icon is displayed instead of thumbnails.
1126 * @return Current thumbnail display status
1127 * @since 6392
1128 */
1129 public boolean isUseThumbs() {
1130 return useThumbs;
1131 }
1132
1133 /**
1134 * Enables or disables the display of thumbnails. Does not update the display.
1135 * @param useThumbs New thumbnail display status
1136 * @since 6392
1137 */
1138 public void setUseThumbs(boolean useThumbs) {
1139 this.useThumbs = useThumbs;
1140 if (useThumbs && !thumbsLoaded) {
1141 startLoadThumbs();
1142 } else if (!useThumbs) {
1143 stopLoadThumbs();
1144 }
1145 }
1146}
Note: See TracBrowser for help on using the repository browser.