    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}