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}