    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}