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