    001// License: GPL. For details, see LICENSE file.
    002package org.openstreetmap.josm.plugins.streetside;
    003
    004import java.awt.AlphaComposite;
    005import java.awt.BasicStroke;
    006import java.awt.Color;
    007import java.awt.Composite;
    008import java.awt.Graphics2D;
    009import java.awt.GraphicsEnvironment;
    010import java.awt.Point;
    011import java.awt.Rectangle;
    012import java.awt.RenderingHints;
    013import java.awt.TexturePaint;
    014import java.awt.geom.Line2D;
    015import java.awt.image.BufferedImage;
    016import java.util.Comparator;
    017import java.util.IntSummaryStatistics;
    018import java.util.Optional;
    019
    020import javax.swing.Action;
    021import javax.swing.Icon;
    022
    023import org.apache.log4j.Logger;
    024import org.openstreetmap.josm.Main;
    025import org.openstreetmap.josm.data.Bounds;
    026import org.openstreetmap.josm.data.osm.DataSet;
    027import org.openstreetmap.josm.data.osm.event.DataChangedEvent;
    028import org.openstreetmap.josm.data.osm.event.DataSetListenerAdapter;
    029import org.openstreetmap.josm.data.osm.visitor.BoundingXYVisitor;
    030import org.openstreetmap.josm.gui.MainApplication;
    031import org.openstreetmap.josm.gui.MapView;
    032import org.openstreetmap.josm.gui.NavigatableComponent;
    033import org.openstreetmap.josm.gui.dialogs.LayerListDialog;
    034import org.openstreetmap.josm.gui.dialogs.LayerListPopup;
    035import org.openstreetmap.josm.gui.layer.AbstractModifiableLayer;
    036import org.openstreetmap.josm.gui.layer.Layer;
    037import org.openstreetmap.josm.gui.layer.MainLayerManager.ActiveLayerChangeEvent;
    038import org.openstreetmap.josm.gui.layer.MainLayerManager.ActiveLayerChangeListener;
    039import org.openstreetmap.josm.plugins.streetside.cache.CacheUtils;
    040import org.openstreetmap.josm.plugins.streetside.gui.StreetsideMainDialog;
    041import org.openstreetmap.josm.plugins.streetside.history.StreetsideRecord;
    042import org.openstreetmap.josm.plugins.streetside.io.download.StreetsideDownloader;
    043import org.openstreetmap.josm.plugins.streetside.io.download.StreetsideDownloader.DOWNLOAD_MODE;
    044import org.openstreetmap.josm.plugins.streetside.mode.AbstractMode;
    045import org.openstreetmap.josm.plugins.streetside.mode.JoinMode;
    046import org.openstreetmap.josm.plugins.streetside.mode.SelectMode;
    047import org.openstreetmap.josm.plugins.streetside.utils.MapViewGeometryUtil;
    048import org.openstreetmap.josm.plugins.streetside.utils.StreetsideColorScheme;
    049import org.openstreetmap.josm.plugins.streetside.utils.StreetsideProperties;
    050import org.openstreetmap.josm.plugins.streetside.utils.StreetsideUtils;
    051import org.openstreetmap.josm.tools.I18n;
    052import org.openstreetmap.josm.tools.ImageProvider.ImageSizes;
    053
    054/**
    055 * This class represents the layer shown in JOSM. There can only exist one
    056 * instance of this object.
    057 *
    058 * @author nokutu
    059 */
    060public final class StreetsideLayer extends AbstractModifiableLayer implements
    061ActiveLayerChangeListener, StreetsideDataListener {
    062
    063  final static Logger logger = Logger.getLogger(StreetsideLayer.class);
    064
    065  /** The radius of the image marker */
    066  private static final int IMG_MARKER_RADIUS = 7;
    067  /** The radius of the circular sector that indicates the camera angle */
    068  private static final int CA_INDICATOR_RADIUS = 15;
    069  /** The angle of the circular sector that indicates the camera angle */
    070  private static final int CA_INDICATOR_ANGLE = 40;
    071
    072        private static final DataSetListenerAdapter DATASET_LISTENER =
    073                        new DataSetListenerAdapter(e -> {
    074                                if (e instanceof DataChangedEvent && StreetsideDownloader.getMode() == DOWNLOAD_MODE.OSM_AREA) {
    075                                        // When more data is downloaded, a delayed update is thrown, in order to
    076                                        // wait for the data bounds to be set.
    077                                        MainApplication.worker.execute(StreetsideDownloader::downloadOSMArea);
    078                                }
    079                        });
    080
    081        /** Unique instance of the class. */
    082        private static StreetsideLayer instance;
    083        /** The nearest images to the selected image from different sequences sorted by distance from selection. */
    084        private StreetsideImage[] nearestImages = {};
    085        /** {@link StreetsideData} object that stores the database. */
    086        private final StreetsideData data;
    087
    088        /** Mode of the layer. */
    089        public AbstractMode mode;
    090
    091        private volatile TexturePaint hatched;
    092        private final StreetsideLocationChangeset locationChangeset = new StreetsideLocationChangeset();
    093
    094        private StreetsideLayer() {
    095                super(I18n.tr("Microsoft Streetside Images"));
    096                data = new StreetsideData();
    097                data.addListener(this);
    098        }
    099
    100  /**
    101   * Initializes the Layer.
    102   */
    103  private void init() {
    104    final DataSet ds = MainApplication.getLayerManager().getEditDataSet();
    105    if (ds != null) {
    106      ds.addDataSetListener(DATASET_LISTENER);
    107    }
    108    MainApplication.getLayerManager().addActiveLayerChangeListener(this);
    109    if (!GraphicsEnvironment.isHeadless()) {
    110      setMode(new SelectMode());
    111      if (StreetsideDownloader.getMode() == DOWNLOAD_MODE.OSM_AREA) {
    112        MainApplication.worker.execute(StreetsideDownloader::downloadOSMArea);
    113      }
    114      if (StreetsideDownloader.getMode() == DOWNLOAD_MODE.VISIBLE_AREA) {
    115        mode.zoomChanged();
    116      }
    117    }
    118    // Does not execute when in headless mode
    119    if (Main.main != null && !StreetsideMainDialog.getInstance().isShowing()) {
    120      StreetsideMainDialog.getInstance().showDialog();
    121    }
    122    if (StreetsidePlugin.getMapView() != null) {
    123      StreetsideMainDialog.getInstance().streetsideImageDisplay.repaint();
    124                }
    125                createHatchTexture();
    126                invalidate();
    127        }
    128
    129  public static void invalidateInstance() {
    130    if (hasInstance()) {
    131      getInstance().invalidate();
    132    }
    133  }
    134
    135  /**
    136   * Changes the mode the the given one.
    137   *
    138   * @param mode The mode that is going to be activated.
    139   */
    140  public void setMode(AbstractMode mode) {
    141    final MapView mv = StreetsidePlugin.getMapView();
    142    if (this.mode != null && mv != null) {
    143      mv.removeMouseListener(this.mode);
    144      mv.removeMouseMotionListener(this.mode);
    145      NavigatableComponent.removeZoomChangeListener(this.mode);
    146    }
    147    this.mode = mode;
    148    if (mode != null && mv != null) {
    149      mv.setNewCursor(mode.cursor, this);
    150      mv.addMouseListener(mode);
    151      mv.addMouseMotionListener(mode);
    152      NavigatableComponent.addZoomChangeListener(mode);
    153      StreetsideUtils.updateHelpText();
    154    }
    155  }
    156
    157  private static synchronized void clearInstance() {
    158    instance = null;
    159  }
    160
    161  /**
    162   * Returns the unique instance of this class.
    163   *
    164   * @return The unique instance of this class.
    165   */
    166  public static synchronized StreetsideLayer getInstance() {
    167    if (instance != null) {
    168      return instance;
    169    }
    170    final StreetsideLayer layer = new StreetsideLayer();
    171    layer.init();
    172    instance = layer; // Only set instance field after initialization is complete
    173    return instance;
    174  }
    175
    176  /**
    177   * @return if the unique instance of this layer is currently instantiated
    178   */
    179  public static boolean hasInstance() {
    180    return instance != null;
    181  }
    182
    183        /**
    184         * Returns the {@link StreetsideData} object, which acts as the database of the
    185         * Layer.
    186         *
    187         * @return The {@link StreetsideData} object that stores the database.
    188         */
    189        public StreetsideData getData() {
    190                return data;
    191        }
    192
    193  /**
    194   * Returns the {@link StreetsideLocationChangeset} object, which acts as the database of the
    195   * Layer.
    196   *
    197   * @return The {@link StreetsideData} object that stores the database.
    198   */
    199  public StreetsideLocationChangeset getLocationChangeset() {
    200    return locationChangeset;
    201  }
    202
    203  /**
    204   * Returns the n-nearest image, for n=1 the nearest one is returned, for n=2 the second nearest one and so on.
    205   * The "n-nearest image" is picked from the list of one image from every sequence that is nearest to the currently
    206   * selected image, excluding the sequence to which the selected image belongs.
    207   * @param n the index for picking from the list of "nearest images", beginning from 1
    208   * @return the n-nearest image to the currently selected image
    209   */
    210  public synchronized StreetsideImage getNNearestImage(final int n) {
    211    return n >= 1 && n <= nearestImages.length ? nearestImages[n - 1] : null;
    212  }
    213
    214  @Override
    215  public synchronized void destroy() {
    216    clearInstance();
    217    setMode(null);
    218    StreetsideRecord.getInstance().reset();
    219    AbstractMode.resetThread();
    220    StreetsideDownloader.stopAll();
    221    if (StreetsideMainDialog.hasInstance()) {
    222      StreetsideMainDialog.getInstance().setImage(null);
    223      StreetsideMainDialog.getInstance().updateImage();
    224    }
    225    final MapView mv = StreetsidePlugin.getMapView();
    226    if (mv != null) {
    227      mv.removeMouseListener(mode);
    228      mv.removeMouseMotionListener(mode);
    229    }
    230    try {
    231      MainApplication.getLayerManager().removeActiveLayerChangeListener(this);
    232      if (MainApplication.getLayerManager().getEditDataSet() != null) {
    233        MainApplication.getLayerManager().getEditDataSet().removeDataSetListener(DATASET_LISTENER);
    234      }
    235    } catch (IllegalArgumentException e) {
    236      // TODO: It would be ideal, to fix this properly. But for the moment let's catch this, for when a listener has already been removed.
    237    }
    238    super.destroy();
    239  }
    240
    241
    242        @Override
    243  public boolean isModified() {
    244    return data.getImages().parallelStream().anyMatch(StreetsideAbstractImage::isModified);
    245  }
    246
    247  @Override
    248  public void setVisible(boolean visible) {
    249    super.setVisible(visible);
    250    getData().getImages().parallelStream().forEach(img -> img.setVisible(visible));
    251  }
    252
    253  /**
    254   * Initialize the hatch pattern used to paint the non-downloaded area.
    255   */
    256  private void createHatchTexture() {
    257    BufferedImage bi = new BufferedImage(15, 15, BufferedImage.TYPE_INT_ARGB);
    258    Graphics2D big = bi.createGraphics();
    259    big.setColor(StreetsideProperties.BACKGROUND.get());
    260    Composite comp = AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.3f);
    261    big.setComposite(comp);
    262    big.fillRect(0, 0, 15, 15);
    263    big.setColor(StreetsideProperties.OUTSIDE_DOWNLOADED_AREA.get());
    264    big.drawLine(0, 15, 15, 0);
    265    Rectangle r = new Rectangle(0, 0, 15, 15);
    266    hatched = new TexturePaint(bi, r);
    267  }
    268
    269  @Override
    270  public synchronized void paint(final Graphics2D g, final MapView mv, final Bounds box) {
    271    g.setRenderingHint(RenderingHints.KEY_ANTIALIASING, RenderingHints.VALUE_ANTIALIAS_ON);
    272    if (MainApplication.getLayerManager().getActiveLayer() == this) {
    273      // paint remainder
    274      g.setPaint(hatched);
    275      g.fill(MapViewGeometryUtil.getNonDownloadedArea(mv, data.getBounds()));
    276    }
    277
    278    // Draw the blue and red line
    279    synchronized (StreetsideLayer.class) {
    280      final StreetsideAbstractImage selectedImg = data.getSelectedImage();
    281      for (int i = 0; i < nearestImages.length && selectedImg != null; i++) {
    282        if (i == 0) {
    283          g.setColor(Color.RED);
    284        } else {
    285          g.setColor(Color.BLUE);
    286        }
    287        final Point selected = mv.getPoint(selectedImg.getMovingLatLon());
    288        final Point p = mv.getPoint(nearestImages[i].getMovingLatLon());
    289        g.draw(new Line2D.Double(p.getX(), p.getY(), selected.getX(), selected.getY()));
    290      }
    291    }
    292
    293    // TODO: Sequence lines removed because Streetside imagery is organized
    294    // such that the images are sorted by the distance from the center of
    295    // the bounding box - Redefine sequences?
    296
    297    // Draw sequence line
    298    /*g.setStroke(new BasicStroke(2));
    299    final StreetsideAbstractImage selectedImage = getData().getSelectedImage();
    300    for (StreetsideSequence seq : getData().getSequences()) {
    301      if (seq.getImages().contains(selectedImage)) {
    302        g.setColor(
    303          seq.getId() == null ? StreetsideColorScheme.SEQ_IMPORTED_SELECTED : StreetsideColorScheme.SEQ_SELECTED
    304        );
    305      } else {
    306        g.setColor(
    307          seq.getId() == null ? StreetsideColorScheme.SEQ_IMPORTED_UNSELECTED : StreetsideColorScheme.SEQ_UNSELECTED
    308        );
    309      }
    310      g.draw(MapViewGeometryUtil.getSequencePath(mv, seq));
    311    }*/
    312    for (StreetsideAbstractImage imageAbs : data.getImages()) {
    313      if (imageAbs.isVisible() && mv != null && mv.contains(mv.getPoint(imageAbs.getMovingLatLon()))) {
    314        drawImageMarker(g, imageAbs);
    315      }
    316    }
    317    if (mode instanceof JoinMode) {
    318      mode.paint(g, mv, box);
    319    }
    320  }
    321
    322  /**
    323   * Draws an image marker onto the given Graphics context.
    324   * @param g the Graphics context
    325   * @param img the image to be drawn onto the Graphics context
    326   */
    327  private void drawImageMarker(final Graphics2D g, final StreetsideAbstractImage img) {
    328    if (img == null || img.getLatLon() == null) {
    329      logger.warn("An image is not painted, because it is null or has no LatLon!");
    330      return;
    331    }
    332    final StreetsideAbstractImage selectedImg = getData().getSelectedImage();
    333    final Point p = MainApplication.getMap().mapView.getPoint(img.getMovingLatLon());
    334
    335    // Determine colors
    336    final Color markerC;
    337    final Color directionC;
    338    if (selectedImg != null && getData().getMultiSelectedImages().contains(img)) {
    339      markerC = img instanceof StreetsideImportedImage
    340        ? StreetsideColorScheme.SEQ_IMPORTED_HIGHLIGHTED
    341        : StreetsideColorScheme.SEQ_HIGHLIGHTED;
    342      directionC = img instanceof StreetsideImportedImage
    343        ? StreetsideColorScheme.SEQ_IMPORTED_HIGHLIGHTED_CA
    344        : StreetsideColorScheme.SEQ_HIGHLIGHTED_CA;
    345    } else if (selectedImg != null && selectedImg.getSequence() != null && selectedImg.getSequence().equals(img.getSequence())) {
    346      markerC = img instanceof StreetsideImportedImage
    347        ? StreetsideColorScheme.SEQ_IMPORTED_SELECTED
    348        : StreetsideColorScheme.SEQ_SELECTED;
    349      directionC = img instanceof StreetsideImportedImage
    350        ? StreetsideColorScheme.SEQ_IMPORTED_SELECTED_CA
    351        : StreetsideColorScheme.SEQ_SELECTED_CA;
    352    } else {
    353      markerC = img instanceof StreetsideImportedImage
    354        ? StreetsideColorScheme.SEQ_IMPORTED_UNSELECTED
    355        : StreetsideColorScheme.SEQ_UNSELECTED;
    356      directionC = img instanceof StreetsideImportedImage
    357        ? StreetsideColorScheme.SEQ_IMPORTED_UNSELECTED_CA
    358        : StreetsideColorScheme.SEQ_UNSELECTED_CA;
    359    }
    360
    361    // Paint direction indicator
    362    g.setColor(directionC);
    363    g.fillArc(p.x - CA_INDICATOR_RADIUS, p.y - CA_INDICATOR_RADIUS, 2 * CA_INDICATOR_RADIUS, 2 * CA_INDICATOR_RADIUS, (int) (90 - img.getMovingHe() - CA_INDICATOR_ANGLE / 2d), CA_INDICATOR_ANGLE);
    364    // Paint image marker
    365    g.setColor(markerC);
    366    g.fillOval(p.x - IMG_MARKER_RADIUS, p.y - IMG_MARKER_RADIUS, 2 * IMG_MARKER_RADIUS, 2 * IMG_MARKER_RADIUS);
    367
    368    // Paint highlight for selected or highlighted images
    369    if (img.equals(getData().getHighlightedImage()) || getData().getMultiSelectedImages().contains(img)) {
    370      g.setColor(Color.WHITE);
    371      g.setStroke(new BasicStroke(2));
    372      g.drawOval(p.x - IMG_MARKER_RADIUS, p.y - IMG_MARKER_RADIUS, 2 * IMG_MARKER_RADIUS, 2 * IMG_MARKER_RADIUS);
    373    }
    374        }
    375
    376  @Override
    377  public Icon getIcon() {
    378    return StreetsidePlugin.LOGO.setSize(ImageSizes.LAYER).get();
    379  }
    380
    381  @Override
    382  public boolean isMergable(Layer other) {
    383    return false;
    384  }
    385
    386  @Override
    387  public void mergeFrom(Layer from) {
    388    throw new UnsupportedOperationException(
    389      "This layer does not support merging yet");
    390  }
    391
    392  @Override
    393  public Action[] getMenuEntries() {
    394    return new Action[]{
    395      LayerListDialog.getInstance().createShowHideLayerAction(),
    396      LayerListDialog.getInstance().createDeleteLayerAction(),
    397      new LayerListPopup.InfoAction(this)
    398    };
    399  }
    400
    401  @Override
    402  public Object getInfoComponent() {
    403    IntSummaryStatistics seqSizeStats = getData().getSequences().stream().mapToInt(seq -> seq.getImages().size()).summaryStatistics();
    404    return new StringBuilder(I18n.tr("Streetside layer"))
    405      .append('\n')
    406      .append(I18n.tr(
    407        "{0} sequences, each containing between {1} and {2} images (ø {3})",
    408        getData().getSequences().size(),
    409        seqSizeStats.getCount() <= 0 ? 0 : seqSizeStats.getMin(),
    410        seqSizeStats.getCount() <= 0 ? 0 : seqSizeStats.getMax(),
    411        seqSizeStats.getAverage()
    412      ))
    413      .append("\n\n")
    414      .append(I18n.tr(
    415        "{0} imported images",
    416        getData().getImages().stream().filter(i -> i instanceof StreetsideImportedImage).count()
    417      ))
    418      .append("\n+ ")
    419      .append(I18n.tr(
    420        "{0} downloaded images",
    421        getData().getImages().stream().filter(i -> i instanceof StreetsideImage).count()
    422      ))
    423      .append("\n= ")
    424      .append(I18n.tr(
    425        "{0} images in total",
    426        getData().getImages().size()
    427      )).toString();
    428  }
    429
    430  @Override
    431  public String getToolTipText() {
    432    return I18n.tr("{0} images in {1} sequences", getData().getImages().size(), getData().getSequences().size());
    433  }
    434
    435  @Override
    436  public void activeOrEditLayerChanged(ActiveLayerChangeEvent e) {
    437    if (MainApplication.getLayerManager().getActiveLayer() == this) {
    438      StreetsideUtils.updateHelpText();
    439    }
    440
    441    if (MainApplication.getLayerManager().getEditLayer() != e.getPreviousDataLayer()) {
    442      if (MainApplication.getLayerManager().getEditLayer() != null) {
    443        MainApplication.getLayerManager().getEditLayer().getDataSet().addDataSetListener(DATASET_LISTENER);
    444      }
    445      if (e.getPreviousDataLayer() != null) {
    446        e.getPreviousDataLayer().getDataSet().removeDataSetListener(DATASET_LISTENER);
    447      }
    448    }
    449  }
    450
    451  @Override
    452  public void visitBoundingBox(BoundingXYVisitor v) {
    453  }
    454
    455  /* (non-Javadoc)
    456   * @see org.openstreetmap.josm.plugins.streetside.StreetsideDataListener#imagesAdded()
    457   */
    458  @Override
    459  public void imagesAdded() {
    460    updateNearestImages();
    461  }
    462
    463  /* (non-Javadoc)
    464   * @see org.openstreetmap.josm.plugins.streetside.StreetsideDataListener#selectedImageChanged(org.openstreetmap.josm.plugins.streetside.StreetsideAbstractImage, org.openstreetmap.josm.plugins.streetside.StreetsideAbstractImage)
    465   */
    466  @Override
    467  public void selectedImageChanged(StreetsideAbstractImage oldImage, StreetsideAbstractImage newImage) {
    468    updateNearestImages();
    469  }
    470
    471  /**
    472   * Returns the closest images belonging to a different sequence and
    473   * different from the specified target image.
    474   *
    475   * @param target the image for which you want to find the nearest other images
    476   * @param limit the maximum length of the returned array
    477   * @return An array containing the closest images belonging to different sequences sorted by distance from target.
    478   */
    479  private StreetsideImage[] getNearestImagesFromDifferentSequences(StreetsideAbstractImage target, int limit) {
    480    return data.getSequences().parallelStream()
    481      .filter(seq -> seq.getId() != null && !seq.getId().equals(target.getSequence().getId()))
    482      .map(seq -> { // Maps sequence to image from sequence that is nearest to target
    483        Optional<StreetsideAbstractImage> resImg = seq.getImages().parallelStream()
    484          .filter(img -> img instanceof StreetsideImage && img.isVisible())
    485          .min(new NearestImgToTargetComparator(target));
    486        return resImg.orElse(null);
    487      })
    488      .filter(img -> // Filters out images too far away from target
    489        img != null &&
    490        img.getMovingLatLon().greatCircleDistance(target.getMovingLatLon())
    491          < StreetsideProperties.SEQUENCE_MAX_JUMP_DISTANCE.get()
    492       )
    493      .sorted(new NearestImgToTargetComparator(target))
    494      .limit(limit)
    495      .toArray(StreetsideImage[]::new);
    496  }
    497
    498  private synchronized void updateNearestImages() {
    499    final StreetsideAbstractImage selected = data.getSelectedImage();
    500    if (selected != null) {
    501      nearestImages = getNearestImagesFromDifferentSequences(selected, 2);
    502    } else {
    503      nearestImages = new StreetsideImage[0];
    504    }
    505    if (MainApplication.isDisplayingMapView()) {
    506      StreetsideMainDialog.getInstance().redButton.setEnabled(nearestImages.length >= 1);
    507      StreetsideMainDialog.getInstance().blueButton.setEnabled(nearestImages.length >= 2);
    508    }
    509    if (nearestImages.length >= 1) {
    510      CacheUtils.downloadPicture(nearestImages[0]);
    511      if (nearestImages.length >= 2) {
    512        CacheUtils.downloadPicture(nearestImages[1]);
    513      }
    514    }
    515  }
    516
    517  private static class NearestImgToTargetComparator implements Comparator<StreetsideAbstractImage> {
    518    private final StreetsideAbstractImage target;
    519
    520    public NearestImgToTargetComparator(StreetsideAbstractImage target) {
    521      this.target = target;
    522    }
    523    /* (non-Javadoc)
    524     * @see java.util.Comparator#compare(java.lang.Object, java.lang.Object)
    525     */
    526    @Override
    527    public int compare(StreetsideAbstractImage img1, StreetsideAbstractImage img2) {
    528      return (int) Math.signum(
    529        img1.getMovingLatLon().greatCircleDistance(target.getMovingLatLon()) -
    530        img2.getMovingLatLon().greatCircleDistance(target.getMovingLatLon())
    531      );
    532    }
    533  }
    534}