001// License: GPL. For details, see LICENSE file.
002package org.openstreetmap.josm.plugins.streetside.gui;
003
004import static org.openstreetmap.josm.tools.I18n.tr;
005
006import java.awt.BasicStroke;
007import java.awt.Color;
008import java.awt.Dimension;
009import java.awt.Graphics;
010import java.awt.Graphics2D;
011import java.awt.Image;
012import java.awt.Point;
013import java.awt.Rectangle;
014import java.awt.Shape;
015import java.awt.event.MouseEvent;
016import java.awt.event.MouseListener;
017import java.awt.event.MouseMotionListener;
018import java.awt.event.MouseWheelEvent;
019import java.awt.event.MouseWheelListener;
020import java.awt.geom.AffineTransform;
021import java.awt.geom.Rectangle2D;
022import java.awt.image.BufferedImage;
023import java.util.ArrayList;
024import java.util.Collection;
025
026import javax.swing.JComponent;
027
028import org.openstreetmap.josm.plugins.streetside.StreetsideLayer;
029import org.openstreetmap.josm.plugins.streetside.actions.StreetsideDownloadAction;
030import org.openstreetmap.josm.plugins.streetside.model.ImageDetection;
031import org.openstreetmap.josm.plugins.streetside.model.MapObject;
032import org.openstreetmap.josm.plugins.streetside.utils.StreetsideColorScheme;
033import org.openstreetmap.josm.plugins.streetside.utils.StreetsideProperties;
034
035
036/**
037 * This object is a responsible JComponent which lets you zoom and drag. It is
038 * included in a {@link StreetsideMainDialog} object.
039 *
040 * @author nokutu
041 * @see StreetsideImageDisplay
042 * @see StreetsideMainDialog
043 */
044public class StreetsideImageDisplay extends JComponent {
045
046  private static final long serialVersionUID = -3188274185432686201L;
047
048  private final Collection<ImageDetection> detections = new ArrayList<>();
049
050  /** The image currently displayed */
051  private volatile BufferedImage image;
052
053  /**
054   * The rectangle (in image coordinates) of the image that is visible. This
055   * rectangle is calculated each time the zoom is modified
056   */
057  private volatile Rectangle visibleRect;
058
059  /**
060   * When a selection is done, the rectangle of the selection (in image
061   * coordinates)
062   */
063  private Rectangle selectedRect;
064
065  private class ImgDisplayMouseListener implements MouseListener, MouseWheelListener, MouseMotionListener {
066    private boolean mouseIsDragging;
067    private long lastTimeForMousePoint;
068    private Point mousePointInImg;
069
070    /**
071     * Zoom in and out, trying to preserve the point of the image that was under
072     * the mouse cursor at the same place
073     */
074    @Override
075    public void mouseWheelMoved(MouseWheelEvent e) {
076      Image image;
077      Rectangle visibleRect;
078      synchronized (StreetsideImageDisplay.this) {
079        image = getImage();
080        visibleRect = StreetsideImageDisplay.this.visibleRect;
081      }
082      mouseIsDragging = false;
083      selectedRect = null;
084      if (image != null && Math.min(getSize().getWidth(), getSize().getHeight()) > 0) {
085        // Calculate the mouse cursor position in image coordinates, so that
086        // we can center the zoom
087        // on that mouse position.
088        // To avoid issues when the user tries to zoom in on the image
089        // borders, this point is not calculated
090        // again if there was less than 1.5seconds since the last event.
091        if (e.getWhen() - lastTimeForMousePoint > 1500 || mousePointInImg == null) {
092          lastTimeForMousePoint = e.getWhen();
093          mousePointInImg = comp2imgCoord(visibleRect, e.getX(), e.getY());
094        }
095        // Set the zoom to the visible rectangle in image coordinates
096        if (e.getWheelRotation() > 0) {
097          visibleRect.width = visibleRect.width * 3 / 2;
098          visibleRect.height = visibleRect.height * 3 / 2;
099        } else {
100          visibleRect.width = visibleRect.width * 2 / 3;
101          visibleRect.height = visibleRect.height * 2 / 3;
102        }
103        // Check that the zoom doesn't exceed 2:1
104        if (visibleRect.width < getSize().width / 2) {
105          visibleRect.width = getSize().width / 2;
106        }
107        if (visibleRect.height < getSize().height / 2) {
108          visibleRect.height = getSize().height / 2;
109        }
110        // Set the same ratio for the visible rectangle and the display area
111        int hFact = visibleRect.height * getSize().width;
112        int wFact = visibleRect.width * getSize().height;
113        if (hFact > wFact) {
114          visibleRect.width = hFact / getSize().height;
115        } else {
116          visibleRect.height = wFact / getSize().width;
117        }
118        // The size of the visible rectangle is limited by the image size.
119        checkVisibleRectSize(image, visibleRect);
120        // Set the position of the visible rectangle, so that the mouse
121        // cursor doesn't move on the image.
122        Rectangle drawRect = calculateDrawImageRectangle(visibleRect);
123        visibleRect.x = mousePointInImg.x
124            + ((drawRect.x - e.getX()) * visibleRect.width) / drawRect.width;
125        visibleRect.y = mousePointInImg.y
126            + ((drawRect.y - e.getY()) * visibleRect.height) / drawRect.height;
127        // The position is also limited by the image size
128        checkVisibleRectPos(image, visibleRect);
129        synchronized (StreetsideImageDisplay.this) {
130          StreetsideImageDisplay.this.visibleRect = visibleRect;
131        }
132        StreetsideImageDisplay.this.repaint();
133      }
134    }
135
136    /** Center the display on the point that has been clicked */
137    @Override
138    public void mouseClicked(MouseEvent e) {
139      // Move the center to the clicked point.
140      Image image;
141      Rectangle visibleRect;
142      synchronized (StreetsideImageDisplay.this) {
143        image = getImage();
144        visibleRect = StreetsideImageDisplay.this.visibleRect;
145      }
146      if (image != null && Math.min(getSize().getWidth(), getSize().getHeight()) > 0) {
147        if (e.getButton() == StreetsideProperties.PICTURE_OPTION_BUTTON.get()) {
148          if (!StreetsideImageDisplay.this.visibleRect.equals(new Rectangle(0, 0, image.getWidth(null), image.getHeight(null)))) {
149            // Zooms to 1:1
150            StreetsideImageDisplay.this.visibleRect = new Rectangle(0, 0,
151                image.getWidth(null), image.getHeight(null));
152          } else {
153            // Zooms to best fit.
154            StreetsideImageDisplay.this.visibleRect = new Rectangle(
155                0,
156                (image.getHeight(null) - (image.getWidth(null) * getHeight()) / getWidth()) / 2,
157                image.getWidth(null),
158                (image.getWidth(null) * getHeight()) / getWidth()
159            );
160          }
161          StreetsideImageDisplay.this.repaint();
162          return;
163        } else if (e.getButton() != StreetsideProperties.PICTURE_DRAG_BUTTON.get()) {
164          return;
165        }
166        // Calculate the translation to set the clicked point the center of
167        // the view.
168        Point click = comp2imgCoord(visibleRect, e.getX(), e.getY());
169        Point center = getCenterImgCoord(visibleRect);
170        visibleRect.x += click.x - center.x;
171        visibleRect.y += click.y - center.y;
172        checkVisibleRectPos(image, visibleRect);
173        synchronized (StreetsideImageDisplay.this) {
174          StreetsideImageDisplay.this.visibleRect = visibleRect;
175        }
176        StreetsideImageDisplay.this.repaint();
177      }
178    }
179
180    /**
181     * Initialize the dragging, either with button 1 (simple dragging) or button
182     * 3 (selection of a picture part)
183     */
184    @Override
185    public void mousePressed(MouseEvent e) {
186      if (getImage() == null) {
187        mouseIsDragging = false;
188        selectedRect = null;
189        return;
190      }
191      Image image;
192      Rectangle visibleRect;
193      synchronized (StreetsideImageDisplay.this) {
194        image = StreetsideImageDisplay.this.image;
195        visibleRect = StreetsideImageDisplay.this.visibleRect;
196      }
197      if (image == null)
198        return;
199      if (e.getButton() == StreetsideProperties.PICTURE_DRAG_BUTTON.get()) {
200        mousePointInImg = comp2imgCoord(visibleRect, e.getX(), e.getY());
201        mouseIsDragging = true;
202        selectedRect = null;
203      } else if (e.getButton() == StreetsideProperties.PICTURE_ZOOM_BUTTON.get()) {
204        mousePointInImg = comp2imgCoord(visibleRect, e.getX(), e.getY());
205        checkPointInVisibleRect(mousePointInImg, visibleRect);
206        mouseIsDragging = false;
207        selectedRect = new Rectangle(mousePointInImg.x, mousePointInImg.y, 0, 0);
208        StreetsideImageDisplay.this.repaint();
209      } else {
210        mouseIsDragging = false;
211        selectedRect = null;
212      }
213    }
214
215    @Override
216    public void mouseDragged(MouseEvent e) {
217      if (!mouseIsDragging && selectedRect == null)
218        return;
219      Image image;
220      Rectangle visibleRect;
221      synchronized (StreetsideImageDisplay.this) {
222        image = getImage();
223        visibleRect = StreetsideImageDisplay.this.visibleRect;
224      }
225      if (image == null) {
226        mouseIsDragging = false;
227        selectedRect = null;
228        return;
229      }
230      if (mouseIsDragging) {
231        Point p = comp2imgCoord(visibleRect, e.getX(), e.getY());
232        visibleRect.x += mousePointInImg.x - p.x;
233        visibleRect.y += mousePointInImg.y - p.y;
234        checkVisibleRectPos(image, visibleRect);
235        synchronized (StreetsideImageDisplay.this) {
236          StreetsideImageDisplay.this.visibleRect = visibleRect;
237        }
238        StreetsideImageDisplay.this.repaint();
239      } else if (selectedRect != null) {
240        Point p = comp2imgCoord(visibleRect, e.getX(), e.getY());
241        checkPointInVisibleRect(p, visibleRect);
242        Rectangle rect = new Rectangle(p.x < mousePointInImg.x ? p.x
243            : mousePointInImg.x, p.y < mousePointInImg.y ? p.y
244            : mousePointInImg.y, p.x < mousePointInImg.x ? mousePointInImg.x
245            - p.x : p.x - mousePointInImg.x,
246            p.y < mousePointInImg.y ? mousePointInImg.y - p.y : p.y
247                - mousePointInImg.y);
248        checkVisibleRectSize(image, rect);
249        checkVisibleRectPos(image, rect);
250        selectedRect = rect;
251        StreetsideImageDisplay.this.repaint();
252      }
253    }
254
255    @Override
256    public void mouseReleased(MouseEvent e) {
257      if (!mouseIsDragging && selectedRect == null)
258        return;
259      Image image;
260      synchronized (StreetsideImageDisplay.this) {
261        image = getImage();
262      }
263      if (image == null) {
264        mouseIsDragging = false;
265        selectedRect = null;
266        return;
267      }
268      if (mouseIsDragging) {
269        mouseIsDragging = false;
270      } else if (selectedRect != null) {
271        int oldWidth = selectedRect.width;
272        int oldHeight = selectedRect.height;
273        // Check that the zoom doesn't exceed 2:1
274        if (selectedRect.width < getSize().width / 2) {
275                selectedRect.width = getSize().width / 2;
276        }
277        if (selectedRect.height < getSize().height / 2) {
278                selectedRect.height = getSize().height / 2;
279        }
280        // Set the same ratio for the visible rectangle and the display
281        // area
282        int hFact = selectedRect.height * getSize().width;
283        int wFact = selectedRect.width * getSize().height;
284        if (hFact > wFact) {
285          selectedRect.width = hFact / getSize().height;
286        } else {
287          selectedRect.height = wFact / getSize().width;
288        }
289        // Keep the center of the selection
290        if (selectedRect.width != oldWidth) {
291                selectedRect.x -= (selectedRect.width - oldWidth) / 2;
292        }
293        if (selectedRect.height != oldHeight) {
294                selectedRect.y -= (selectedRect.height - oldHeight) / 2;
295        }
296        checkVisibleRectSize(image, selectedRect);
297        checkVisibleRectPos(image, selectedRect);
298        synchronized (StreetsideImageDisplay.this) {
299          visibleRect = selectedRect;
300        }
301        selectedRect = null;
302        StreetsideImageDisplay.this.repaint();
303      }
304    }
305
306    @Override
307    public void mouseEntered(MouseEvent e) {
308      // Do nothing, method is enforced by MouseListener
309    }
310
311    @Override
312    public void mouseExited(MouseEvent e) {
313      // Do nothing, method is enforced by MouseListener
314    }
315
316    @Override
317    public void mouseMoved(MouseEvent e) {
318      // Do nothing, method is enforced by MouseListener
319    }
320
321    private void checkPointInVisibleRect(Point p, Rectangle visibleRect) {
322      if (p.x < visibleRect.x) {
323        p.x = visibleRect.x;
324      }
325      if (p.x > visibleRect.x + visibleRect.width) {
326        p.x = visibleRect.x + visibleRect.width;
327      }
328      if (p.y < visibleRect.y) {
329        p.y = visibleRect.y;
330      }
331      if (p.y > visibleRect.y + visibleRect.height) {
332        p.y = visibleRect.y + visibleRect.height;
333      }
334    }
335  }
336
337  /**
338   * Main constructor.
339   */
340  public StreetsideImageDisplay() {
341    ImgDisplayMouseListener mouseListener = new ImgDisplayMouseListener();
342    addMouseListener(mouseListener);
343    addMouseWheelListener(mouseListener);
344    addMouseMotionListener(mouseListener);
345
346    StreetsideProperties.SHOW_DETECTED_SIGNS.addListener(valChanged -> repaint());
347  }
348
349  /**
350   * Sets a new picture to be displayed.
351   *
352   * @param image The picture to be displayed.
353   * @param detections image detections
354   */
355  public void setImage(BufferedImage image, Collection<ImageDetection> detections) {
356    synchronized (this) {
357      this.image = image;
358      this.detections.clear();
359      if (detections != null) {
360        this.detections.addAll(detections);
361      }
362      selectedRect = null;
363      if (image != null)
364        visibleRect = new Rectangle(0, 0, image.getWidth(null),
365            image.getHeight(null));
366    }
367    repaint();
368  }
369
370  /**
371   * Returns the picture that is being displayed
372   *
373   * @return The picture that is being displayed.
374   */
375  public BufferedImage getImage() {
376    return image;
377  }
378
379  /**
380   * Paints the visible part of the picture.
381   */
382  @Override
383  public void paintComponent(Graphics g) {
384    Image image;
385    Rectangle visibleRect;
386    synchronized (this) {
387      image = this.image;
388      visibleRect = this.visibleRect;
389    }
390    if (image == null) {
391      g.setColor(Color.black);
392      String noImageStr = StreetsideLayer.hasInstance() ? tr("No image selected") : tr("Press \"{0}\" to download images", StreetsideDownloadAction.SHORTCUT.getKeyText());
393      Rectangle2D noImageSize = g.getFontMetrics(g.getFont()).getStringBounds(
394          noImageStr, g);
395      Dimension size = getSize();
396      g.drawString(noImageStr,
397          (int) ((size.width - noImageSize.getWidth()) / 2),
398          (int) ((size.height - noImageSize.getHeight()) / 2));
399    } else {
400      Rectangle target = calculateDrawImageRectangle(visibleRect);
401      g.drawImage(image, target.x, target.y, target.x + target.width, target.y
402          + target.height, visibleRect.x, visibleRect.y, visibleRect.x
403          + visibleRect.width, visibleRect.y + visibleRect.height, null);
404      if (selectedRect != null) {
405        Point topLeft = img2compCoord(visibleRect, selectedRect.x,
406            selectedRect.y);
407        Point bottomRight = img2compCoord(visibleRect, selectedRect.x
408            + selectedRect.width, selectedRect.y + selectedRect.height);
409        g.setColor(new Color(128, 128, 128, 180));
410        g.fillRect(target.x, target.y, target.width, topLeft.y - target.y);
411        g.fillRect(target.x, target.y, topLeft.x - target.x, target.height);
412        g.fillRect(bottomRight.x, target.y, target.x + target.width
413            - bottomRight.x, target.height);
414        g.fillRect(target.x, bottomRight.y, target.width, target.y
415            + target.height - bottomRight.y);
416        g.setColor(Color.black);
417        g.drawRect(topLeft.x, topLeft.y, bottomRight.x - topLeft.x,
418            bottomRight.y - topLeft.y);
419      }
420
421      if (StreetsideProperties.SHOW_DETECTED_SIGNS.get()) {
422        Point upperLeft = img2compCoord(visibleRect, 0, 0);
423        Point lowerRight = img2compCoord(visibleRect, getImage().getWidth(), getImage().getHeight());
424
425        // Transformation, which can convert you a Shape relative to the unit square to a Shape relative to the Component
426        AffineTransform unit2compTransform = AffineTransform.getTranslateInstance(upperLeft.getX(), upperLeft.getY());
427        unit2compTransform.concatenate(AffineTransform.getScaleInstance(lowerRight.getX() - upperLeft.getX(), lowerRight.getY() - upperLeft.getY()));
428
429        final Graphics2D g2d = (Graphics2D) g;
430        g2d.setStroke(new BasicStroke(2));
431        for (ImageDetection d : detections) {
432          final Shape shape = d.getShape().createTransformedShape(unit2compTransform);
433          g2d.setColor(d.isTrafficSign() ? StreetsideColorScheme.IMAGEDETECTION_TRAFFICSIGN : StreetsideColorScheme.IMAGEDETECTION_UNKNOWN);
434          g2d.draw(shape);
435          if (d.isTrafficSign()) {
436            g2d.drawImage(
437              MapObject.getIcon(d.getValue()).getImage(),
438              shape.getBounds().x, shape.getBounds().y,
439              shape.getBounds().width, shape.getBounds().height,
440              null
441            );
442          }
443        }
444      }
445    }
446  }
447
448  private Point img2compCoord(Rectangle visibleRect, int xImg, int yImg) {
449    Rectangle drawRect = calculateDrawImageRectangle(visibleRect);
450    return new Point(drawRect.x + ((xImg - visibleRect.x) * drawRect.width)
451        / visibleRect.width, drawRect.y
452        + ((yImg - visibleRect.y) * drawRect.height) / visibleRect.height);
453  }
454
455  private Point comp2imgCoord(Rectangle visibleRect, int xComp, int yComp) {
456    Rectangle drawRect = calculateDrawImageRectangle(visibleRect);
457    return new Point(
458        visibleRect.x + ((xComp - drawRect.x) * visibleRect.width) / drawRect.width,
459        visibleRect.y + ((yComp - drawRect.y) * visibleRect.height) / drawRect.height
460    );
461  }
462
463  private static Point getCenterImgCoord(Rectangle visibleRect) {
464    return new Point(visibleRect.x + visibleRect.width / 2, visibleRect.y + visibleRect.height / 2);
465  }
466
467  private Rectangle calculateDrawImageRectangle(Rectangle visibleRect) {
468    return calculateDrawImageRectangle(visibleRect, new Rectangle(0, 0, getSize().width, getSize().height));
469  }
470
471  /**
472   * calculateDrawImageRectangle
473   *
474   * @param imgRect
475   *          the part of the image that should be drawn (in image coordinates)
476   * @param compRect
477   *          the part of the component where the image should be drawn (in
478   *          component coordinates)
479   * @return the part of compRect with the same width/height ratio as the image
480   */
481  private static Rectangle calculateDrawImageRectangle(Rectangle imgRect, Rectangle compRect) {
482    int x = 0;
483    int y = 0;
484    int w = compRect.width;
485    int h = compRect.height;
486    int wFact = w * imgRect.height;
487    int hFact = h * imgRect.width;
488    if (wFact != hFact) {
489      if (wFact > hFact) {
490        w = hFact / imgRect.height;
491        x = (compRect.width - w) / 2;
492      } else {
493        h = wFact / imgRect.width;
494        y = (compRect.height - h) / 2;
495      }
496    }
497    return new Rectangle(x + compRect.x, y + compRect.y, w, h);
498  }
499
500  /**
501   * Zooms to 1:1 and, if it is already in 1:1, to best fit.
502   */
503  public void zoomBestFitOrOne() {
504    Image image;
505    Rectangle visibleRect;
506    synchronized (this) {
507      image = this.image;
508      visibleRect = this.visibleRect;
509    }
510    if (image == null)
511      return;
512    if (visibleRect.width != image.getWidth(null)
513        || visibleRect.height != image.getHeight(null)) {
514      // The display is not at best fit. => Zoom to best fit
515      visibleRect = new Rectangle(0, 0, image.getWidth(null),
516          image.getHeight(null));
517    } else {
518      // The display is at best fit => zoom to 1:1
519      Point center = getCenterImgCoord(visibleRect);
520      visibleRect = new Rectangle(center.x - getWidth() / 2, center.y
521          - getHeight() / 2, getWidth(), getHeight());
522      checkVisibleRectPos(image, visibleRect);
523    }
524    synchronized (this) {
525      this.visibleRect = visibleRect;
526    }
527    repaint();
528  }
529
530  private static void checkVisibleRectPos(Image image, Rectangle visibleRect) {
531    if (visibleRect.x < 0) {
532      visibleRect.x = 0;
533    }
534    if (visibleRect.y < 0) {
535      visibleRect.y = 0;
536    }
537    if (visibleRect.x + visibleRect.width > image.getWidth(null)) {
538      visibleRect.x = image.getWidth(null) - visibleRect.width;
539    }
540    if (visibleRect.y + visibleRect.height > image.getHeight(null)) {
541      visibleRect.y = image.getHeight(null) - visibleRect.height;
542    }
543  }
544
545  private static void checkVisibleRectSize(Image image, Rectangle visibleRect) {
546    if (visibleRect.width > image.getWidth(null)) {
547      visibleRect.width = image.getWidth(null);
548    }
549    if (visibleRect.height > image.getHeight(null)) {
550      visibleRect.height = image.getHeight(null);
551    }
552  }
553}