001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.plugins.streetside.utils;
003
004import java.awt.Desktop;
005import java.io.IOException;
006import java.net.URISyntaxException;
007import java.net.URL;
008import java.text.ParseException;
009import java.text.SimpleDateFormat;
010import java.util.Calendar;
011import java.util.Locale;
012import java.util.Set;
013
014import javax.swing.SwingUtilities;
015
016import org.apache.commons.imaging.common.RationalNumber;
017import org.apache.commons.imaging.formats.tiff.constants.GpsTagConstants;
018
019import org.openstreetmap.josm.data.Bounds;
020import org.openstreetmap.josm.data.coor.LatLon;
021import org.openstreetmap.josm.gui.MainApplication;
022import org.openstreetmap.josm.plugins.streetside.StreetsideAbstractImage;
023import org.openstreetmap.josm.plugins.streetside.StreetsideLayer;
024import org.openstreetmap.josm.plugins.streetside.StreetsideSequence;
025import org.openstreetmap.josm.tools.I18n;
026
027/**
028 * Set of utilities.
029 *
030 * @author nokutu
031 */
032public final class StreetsideUtils {
033
034  private static final double MIN_ZOOM_SQUARE_SIDE = 0.002;
035
036  private StreetsideUtils() {
037    // Private constructor to avoid instantiation
038  }
039
040  /**
041   * Open the default browser in the given URL.
042   *
043   * @param url The (not-null) URL that is going to be opened.
044   * @throws IOException when the URL could not be opened
045   */
046  public static void browse(URL url) throws IOException {
047    if (url == null) {
048      throw new IllegalArgumentException();
049    }
050    Desktop desktop = Desktop.getDesktop();
051    if (desktop.isSupported(Desktop.Action.BROWSE)) {
052      try {
053        desktop.browse(url.toURI());
054      } catch (URISyntaxException e1) {
055        throw new IOException(e1);
056      }
057    } else {
058      Runtime runtime = Runtime.getRuntime();
059      runtime.exec("xdg-open " + url);
060    }
061  }
062
063  /**
064   * Returns the current date formatted as EXIF timestamp.
065   * As timezone the default timezone of the JVM is used ({@link java.util.TimeZone#getDefault()}).
066   *
067   * @return A {@code String} object containing the current date.
068   */
069  public static String currentDate() {
070    return new SimpleDateFormat("yyyy:MM:dd HH:mm:ss", Locale.UK).format(Calendar.getInstance().getTime());
071  }
072
073  /**
074   * Returns current time in Epoch format (milliseconds since 1970-01-01T00:00:00+0000)
075   *
076   * @return The current date in Epoch format.
077   */
078  public static long currentTime() {
079    return Calendar.getInstance().getTimeInMillis();
080  }
081
082  /**
083   * Parses a string with a given format and returns the Epoch time.
084   * If no timezone information is given, the default timezone of the JVM is used
085   * ({@link java.util.TimeZone#getDefault()}).
086   *
087   * @param date   The string containing the date.
088   * @param format The format of the date.
089   * @return The date in Epoch format.
090   * @throws ParseException if the date cannot be parsed with the given format
091   */
092  public static long getEpoch(String date, String format) throws ParseException {
093    return new SimpleDateFormat(format, Locale.UK).parse(date).getTime();
094  }
095
096  /**
097   * Calculates the decimal degree-value from a degree value given in
098   * degrees-minutes-seconds-format
099   *
100   * @param degMinSec an array of length 3, the values in there are (in this order)
101   *                  degrees, minutes and seconds
102   * @param ref       the latitude or longitude reference determining if the given value
103   *                  is:
104   *                  <ul>
105   *                  <li>north (
106   *                  {@link GpsTagConstants#GPS_TAG_GPS_LATITUDE_REF_VALUE_NORTH}) or
107   *                  south (
108   *                  {@link GpsTagConstants#GPS_TAG_GPS_LATITUDE_REF_VALUE_SOUTH}) of
109   *                  the equator</li>
110   *                  <li>east (
111   *                  {@link GpsTagConstants#GPS_TAG_GPS_LONGITUDE_REF_VALUE_EAST}) or
112   *                  west ({@link GpsTagConstants#GPS_TAG_GPS_LONGITUDE_REF_VALUE_WEST}
113   *                  ) of the equator</li>
114   *                  </ul>
115   * @return the decimal degree-value for the given input, negative when west of
116   * 0-meridian or south of equator, positive otherwise
117   * @throws IllegalArgumentException if {@code degMinSec} doesn't have length 3 or if {@code ref} is
118   *                                  not one of the values mentioned above
119   */
120  public static double degMinSecToDouble(RationalNumber[] degMinSec, String ref) {
121    if (degMinSec == null || degMinSec.length != 3) {
122      throw new IllegalArgumentException("Array's length must be 3.");
123    }
124    for (int i = 0; i < 3; i++) {
125      if (degMinSec[i] == null)
126        throw new IllegalArgumentException("Null value in array.");
127    }
128
129    switch (ref) {
130      case GpsTagConstants.GPS_TAG_GPS_LATITUDE_REF_VALUE_NORTH:
131      case GpsTagConstants.GPS_TAG_GPS_LATITUDE_REF_VALUE_SOUTH:
132      case GpsTagConstants.GPS_TAG_GPS_LONGITUDE_REF_VALUE_EAST:
133      case GpsTagConstants.GPS_TAG_GPS_LONGITUDE_REF_VALUE_WEST:
134        break;
135      default:
136        throw new IllegalArgumentException("Invalid ref.");
137    }
138
139    double result = degMinSec[0].doubleValue(); // degrees
140    result += degMinSec[1].doubleValue() / 60; // minutes
141    result += degMinSec[2].doubleValue() / 3600; // seconds
142
143    if (GpsTagConstants.GPS_TAG_GPS_LATITUDE_REF_VALUE_SOUTH.equals(ref)
144            || GpsTagConstants.GPS_TAG_GPS_LONGITUDE_REF_VALUE_WEST.equals(ref)) {
145      result *= -1;
146    }
147
148    result = 360 * ((result + 180) / 360 - Math.floor((result + 180) / 360)) - 180;
149    return result;
150  }
151
152  /**
153   * Joins two images into the same sequence. One of them must be the last image of a sequence, the other one the beginning of a different one.
154   *
155   * @param imgA the first image, into whose sequence the images from the sequence of the second image are merged
156   * @param imgB the second image, whose sequence is merged into the sequence of the first image
157   */
158  public static synchronized void join(StreetsideAbstractImage imgA, StreetsideAbstractImage imgB) {
159    if (imgA == null || imgB == null) {
160      throw new IllegalArgumentException("Both images must be non-null for joining.");
161    }
162    if (imgA.getSequence() == imgB.getSequence()) {
163      throw new IllegalArgumentException("You can only join images of different sequences.");
164    }
165    if ((imgA.next() != null || imgB.previous() != null) && (imgB.next() != null || imgA.previous() != null)) {
166      throw new IllegalArgumentException("You can only join an image at the end of a sequence with one at the beginning of another sequence.");
167    }
168    if (imgA.next() != null || imgB.previous() != null) {
169      join(imgB, imgA);
170    } else {
171      for (StreetsideAbstractImage img : imgB.getSequence().getImages()) {
172        imgA.getSequence().add(img);
173      }
174      StreetsideLayer.invalidateInstance();
175    }
176  }
177
178  /**
179   * Zooms to fit all the {@link StreetsideAbstractImage} objects stored in the
180   * database.
181   */
182  public static void showAllPictures() {
183    showPictures(StreetsideLayer.getInstance().getData().getImages(), false);
184  }
185
186  /**
187   * Zooms to fit all the given {@link StreetsideAbstractImage} objects.
188   *
189   * @param images The images your are zooming to.
190   * @param select Whether the added images must be selected or not.
191   */
192  public static void showPictures(final Set<StreetsideAbstractImage> images, final boolean select) {
193    if (!SwingUtilities.isEventDispatchThread()) {
194      SwingUtilities.invokeLater(() -> showPictures(images, select));
195    } else {
196      Bounds zoomBounds;
197      if (images.isEmpty()) {
198        zoomBounds = new Bounds(new LatLon(0, 0));
199      } else {
200        zoomBounds = new Bounds(images.iterator().next().getMovingLatLon());
201        for (StreetsideAbstractImage img : images) {
202          zoomBounds.extend(img.getMovingLatLon());
203        }
204      }
205
206      // The zoom rectangle must have a minimum size.
207      double latExtent = Math.max(zoomBounds.getMaxLat() - zoomBounds.getMinLat(), MIN_ZOOM_SQUARE_SIDE);
208      double lonExtent = Math.max(zoomBounds.getMaxLon() - zoomBounds.getMinLon(), MIN_ZOOM_SQUARE_SIDE);
209      zoomBounds = new Bounds(zoomBounds.getCenter(), latExtent, lonExtent);
210
211      MainApplication.getMap().mapView.zoomTo(zoomBounds);
212      StreetsideLayer.getInstance().getData().setSelectedImage(null);
213      if (select) {
214        StreetsideLayer.getInstance().getData().addMultiSelectedImage(images);
215      }
216      StreetsideLayer.invalidateInstance();
217    }
218
219  }
220
221  /**
222   * Separates two images belonging to the same sequence. The two images have to be consecutive in the same sequence.
223   * Two new sequences are created and all images up to (and including) either {@code imgA} or {@code imgB} (whichever appears first in the sequence) are put into the first of the two sequences.
224   * All others are put into the second new sequence.
225   *
226   * @param imgA one of the images marking where to split the sequence
227   * @param imgB the other image marking where to split the sequence, needs to be a direct neighbour of {@code imgA} in the sequence.
228   */
229  public static synchronized void unjoin(StreetsideAbstractImage imgA, StreetsideAbstractImage imgB) {
230    if (imgA == null || imgB == null) {
231      throw new IllegalArgumentException("Both images must be non-null for unjoining.");
232    }
233    if (imgA.getSequence() != imgB.getSequence()) {
234      throw new IllegalArgumentException("You can only unjoin with two images from the same sequence.");
235    }
236    if (imgB.equals(imgA.next()) && imgA.equals(imgB.next())) {
237      throw new IllegalArgumentException("When unjoining with two images these must be consecutive in one sequence.");
238    }
239
240    if (imgA.equals(imgB.next())) {
241      unjoin(imgB, imgA);
242    } else {
243      StreetsideSequence seqA = new StreetsideSequence();
244      StreetsideSequence seqB = new StreetsideSequence();
245      boolean insideFirstHalf = true;
246      for (StreetsideAbstractImage img : imgA.getSequence().getImages()) {
247        if (insideFirstHalf) {
248          seqA.add(img);
249        } else {
250          seqB.add(img);
251        }
252        if (img.equals(imgA)) {
253          insideFirstHalf = false;
254        }
255      }
256      StreetsideLayer.invalidateInstance();
257    }
258  }
259
260  /**
261   * Updates the help text at the bottom of the window.
262   */
263  public static void updateHelpText() {
264    if (MainApplication.getMap() == null || MainApplication.getMap().statusLine == null) {
265      return;
266    }
267    StringBuilder ret = new StringBuilder();
268    if (PluginState.isDownloading()) {
269      ret.append(I18n.tr("Downloading Streetside images"));
270    } else if (StreetsideLayer.hasInstance() && !StreetsideLayer.getInstance().getData().getImages().isEmpty()) {
271      ret.append(I18n.tr("Total Streetside images: {0}", StreetsideLayer.getInstance().getToolTipText()));
272    } else if (PluginState.isSubmittingChangeset()) {
273        ret.append(I18n.tr("Submitting Streetside Changeset"));
274    } else {
275      ret.append(I18n.tr("No images found"));
276    }
277    if (StreetsideLayer.hasInstance() && StreetsideLayer.getInstance().mode != null) {
278      ret.append(" — ").append(I18n.tr(StreetsideLayer.getInstance().mode.toString()));
279    }
280    if (PluginState.isUploading()) {
281      ret.append(" — ").append(PluginState.getUploadString());
282    }
283    MainApplication.getMap().statusLine.setHelpText(ret.toString());
284  }
285}