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