    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}