Re: Can't get zooming into a selected area to work correctly.



On Sat, 31 May 2008 11:58:55 -0700, RichT <someone@xxxxxxxxxxxxx> wrote:

Hi all again,

I am having problems getting my zoom into area working properly(at all)?

The idea being that the mouse is dragged over an area and the area within the selection rectangle is scaled and then centred to fit in the scrollpane.

I am using getCenterX & Y of the selection rectangle to centre in scrollpane.

The image is scaled, but does not centre within the scroll pane.

There are two big problems. One is that you're misusing the JViewport.setViewPosition() method. The point passed to that method specifies what point of the contained view will be in the upper-left corner of the viewport, not the center.

The other is how you're setting the view component size after you've scrolled the viewport. You're setting the preferred size to the size of your image, unscaled. The image is still drawn scaled, but because the component is the wrong size, you can only see a little bit of it.

A problem that is not directly related to this zooming behavior is that you've use the setScale() method to set your "isCentered" flag. This is very wrong. The setScale() method is semantically a "property setter". It's not just some random method that's used to set the scale for the view. It is _the_ way to set the scale for the view. You should only ever change the scale for the view through that method, and that method should _only_ do things that are directly related to _all_ changes of the scale, not just a specific scenario.

If you want the view component to have an "isCentered" mode, then add another property that the client can manipulate. Have the client code (the frame) set the property to true any time it is setting the scale explicitly, and then (if I understand your intent correctly) have the view itself set the property to false when it's changing the scale via a drag-zoom mouse input. Don't try to incorporate that behavior into the property setter for the scale; it doesn't belong there.

The biggest immediate reason that this is an issue is that the code you posted has additional places where it tries to manipulate the scale for the view, without going through the setScale() method. That's very bad...that method contains logic to handle all of the things that must be dealt with any time the scale changes. Trying to repeat that logic everywhere else in the code every time is wasteful at best, and likely to be done wrong at worst. You need to keep the setScale() method focused on doing just the management of that particular property. Don't add unrelated functionality to it, otherwise you'll find it's not useful for the original intended purpose.

I also want to be able to do the following:
Once The selection has been zoomed and centred, I want to be able to make another selection within that selection and zoom in again.

I did manage to make the image selection work earlier, but once it centred the zoomed selection, doing another selection gave odd results sometimes moving to top left of image or some other random place within the image.

Another problem is that you are miscalculating your scale factor with respect to the zoom rectangle. The calculation you do in calculateSelectionToFit() is relative to the current view scaling. Ironically, while it's a very bad idea to mess with the transform directly in your zoomArea() method, it's actually the copying of the calculated scale into the _scale field that's a problem. The _scale field should be the current absolute scaling of the image, but you wind up making it the relative scaling.

In the code you posted, I'm not sure this is ever actually a problem, because you only use the _scale field in places where you should have used the transform instead. :) But it's not really a clean, maintainable way to approach it. Again, the simpler you can keep the code, the harder it will be to have bugs in it.

There may be other subtle bugs I didn't bother to comment on, or even didn't notice. I didn't work very hard to try to fix up the code you posted, because IMHO it's not really the right direction. You again seem to be making things harder on yourself than they need to be. :)

IMHO, the best approach would be to do this:

-- transform the client coordinates from the mouse back to image coordinates
-- use the image coordinates to calculate an absolute scale for the image
-- use the setScale() method in the component to apply that specific scale
-- finally, reposition the JScrollPane viewport as necessary

On the last point, it's my opinion that again, doing some of the calculation in image coordinates will simplify the code. In particular, find the centerpoint of your zoom rectangle, as given in image coordinates, and then after you've changed the scale for the component, translate that centerpoint back to client coordinates and then finally adjust the viewport so that the centerpoint is in fact centered.

Note, of course, that since the JViewport.setViewPosition() method sets the upper-left, not the center, you'll need to write a little extra code to handle that. But the code to convert a client-coordinate center point to a client-coordinate upper-left is fairly easy: just subtract half the width and height of the viewport.

Doing the coordinate system conversions is not strictly speaking necessary. If you're careful, you could do this without at least some of the conversions. But the code would be a lot more complicated, and we're not really talking about a lot of extra work here. I think keeping the expression of the code simpler and more obvious is more important than worrying about what's the least amount of code.

Finally, I notice that you are relying on the client of the component to tell it when it's in a scroll pane. This is reasonably acceptable, I guess. However, I would make the code more general-purpose and more robust by simply overriding addNotify() in the component instead of having the setScrollPane() method, and checking for if and when the parent is a JViewport.

I'm still trying to figure out if there's a way to break apart the viewport and view component logic more. I have a gut feeling that's telling me that even as things are now, the view component knows too much about the JScrollPane. But I haven't come up with anything that I think is better than doing things as they are now. Maybe it does in fact make sense for the view component to treat the JScrollPane as just a sort of "add-on" to itself and manage it directly. But it still bugs me a little to do that.

Anyway...sorry for the essay. I had a lot on my mind. :)

As I mentioned, I did not try to get the code you posted working. However, I _did_ go ahead and implement two versions of the "drag to zoom" behavior. In doing so, I not only came up with some code you might find helpful (see below...both versions are in the same code, selected via the "_fConstrainedDrag" field so you can compare/contrast the two versions more easily), I also learned a little more about how the viewport and layout management works (in fact, this is one of the best things about trying to help others...you learn stuff yourself :) ).

One of the things I noticed was that you keep inserting these calls to EventQueue.invokeLater(). Those always seemed weird to me, but I didn't really understand why you were doing it (other than perhaps Knute recommended it). I think I finally figured it out, at least partially. They seem to exist in order to deal with the fact that when you change the size of the view component, it doesn't perform a new layout for the viewport immediately, but rather simply marks the viewport as needing to be laid out again. Putting things that depend on the layout in the invokeLater() code ensures that the re-layout happens before the dependent code runs.

However, IMHO there's a better way. The invokeLater() strategy might be useful in a situation where you expect to be invalidating the layout over and over before you ever get a chance to actually do the re-layout and then the dependent code (assuming such a situation can exist...I'm not convinced that a sufficiently extreme example to justify the complexity does, but that's another topic :) ). However, in this case we're dealing with the end of a modal state the user has entered, and we know we're just going to invalidate the layout just the once.

So, instead of using the invokeLater() stuff and complicating the code, just force the re-layout to occur by calling the validate() method on the viewport. I didn't implement any of the "modal centering" logic related to your "isCentered" flag, but you should be able to do all of that without any calls to invokeLater() using the same technique I'm describing, as necessary.

Another thing I noticed is that there's a scrollRectToVisible() method in the JComponent class. It turns out that this particular method is forwarded to the parent of the JComponent. It percolates up until it reaches (for example :) ) a JViewport that has its own implementation, which is to actually perform the scroll necessary. The JViewport method even helpfully does any necessary validation before doing the scrolling.

One of the versions of the "drag to zoom" UI I did takes advantage of all this by constraining the dragged rectangle to fit the viewport exactly. That way, when the user is done dragging, I know that I can scroll to the dragged rectangle exactly without worrying about the centerpoint. The dragged rectangle will be centered because it's got nowhere else to go. :) The other nice thing is that you don't even have to call the viewport's scrollRectToVisible(). Every JComponent already has that, and passing it up to the parent is done for you automatically.

For either "drag to zoom" implementation I did, you still need the viewport, because you depend on it either for the aspect ratio of the drag rectangle, or for dealing with centering the center point of the drag rectangle. But if you're not picky about either of those, you could in fact just do the unconstrained drag rectangle calculations and call the JComponent's scrollRectToVisible() method without knowing anything about the viewport at all (in that case, rather than centering the user-selected area, the view would be scrolled just enough so that the user-selected area was fully visible within the viewport).

Finally, you'll note in the code I posted that I went ahead and showed how I'd use the addNotify() method to manage knowing the viewport, rather than relying on the client of the class. This way, the component always behaves properly when inside a viewport, and the client of the component doesn't need to worry about it at all (when adding these changes, I didn't touch any other code...just the ImageSelectComponent class).

Anyway, you've put up with quite a lot of my rambling already. I'll wrap things up here, and include my modified code below. Have fun. :)

Pete


import java.awt.*;
import java.awt.event.*;
import java.awt.geom.*;
import java.awt.image.*;
import javax.swing.*;
import javax.swing.event.*;


public class ImageSelectComponent extends JComponent implements MouseInputListener
{
private BufferedImage _image;
private float _scale = 1.0f;

private boolean _fDragging;
private Point2D _ptDragStart;
private Point2D _ptDragCur;
private Rectangle2D _rectDragBounds;
private double _ratioDragAspect;

private AffineTransform _transformToClient = new AffineTransform();
private AffineTransform _transformFromClient = new AffineTransform();

private JViewport _viewport;
private boolean _fConstrainedDrag = false;

public ImageSelectComponent()
{
this.addMouseListener(this);
this.addMouseMotionListener(this);
}

public void addNotify()
{
super.addNotify();

Component compParent = getParent();

if (compParent instanceof JViewport)
{
_viewport = (JViewport)compParent;
}
else
{
_viewport = null;
}
}

public void removeNotify()
{
super.removeNotify();

_viewport = null;
}

public void setImage(BufferedImage image)
{
// I'm only copying the provided image because later on, I found that
// the entire image got cleared to black when I tried to write to it.
// Seems to be related to the fact that the image is from a file, and
// this might be a Mac-only issue (bug?). In any case, copying the
// passed-in image to a new one allows me to write to it later without
// any trouble.
_image = new BufferedImage(image.getWidth(), image.getHeight(), BufferedImage.TYPE_INT_ARGB);

Graphics2D gfx = _image.createGraphics();

gfx.drawImage(image, 0, 0, null);
gfx.dispose();

_UpdateMetrics();
}

public BufferedImage getImage()
{
return _image;
}

public void setScale(float scale)
{
_scale = scale;
_transformToClient = AffineTransform.getScaleInstance(_scale, _scale);
_transformFromClient = AffineTransform.getScaleInstance(1 / _scale, 1 / _scale);
_UpdateMetrics();
}

public float getScale()
{
return _scale;
}

protected void paintComponent(Graphics gfxArg)
{
Graphics2D gfx = (Graphics2D)gfxArg;

// Draw the image with the current transformation
if (_image != null)
{
AffineTransform transformSav = gfx.getTransform();

gfx.transform(_transformToClient);
// gfx.drawImage(_image, 0, 0, Math.round(_image.getWidth() * _scale), Math.round(_image.getHeight() * _scale), null);
gfx.drawImage(_image, 0, 0, null);
gfx.setTransform(transformSav);
}

// Drag data is already in client coordinates
if (_fDragging)
{
gfx.draw(_RectClientFromDrag());
}
}

private void _UpdateMetrics()
{
if (_image != null)
{
Rectangle2D rectBounds =
Util.RectTransform(_transformToClient, new Rectangle(0, 0, _image.getWidth(), _image.getHeight()));
// Dimension sizeNew = new Dimension(Math.round(_image.getWidth() * _scale), Math.round(_image.getHeight() * _scale));
Dimension sizeNew = new Dimension((int)Math.round(rectBounds.getWidth()), (int)Math.round(rectBounds.getHeight()));

setMinimumSize(sizeNew);
setMaximumSize(sizeNew);
setPreferredSize(sizeNew);
setSize(sizeNew);
}
}

public void mouseClicked(MouseEvent arg0)
{
}

public void mouseEntered(MouseEvent arg0)
{
}

public void mouseExited(MouseEvent arg0)
{
}

public void mousePressed(MouseEvent arg0)
{
_fDragging = true;
_ptDragStart = arg0.getPoint();

// To determine limits of mouse input, start with a rectangle in
// the image coordinates describing the whole image, and transform
// that rectangle into client coordinates
// _rectDragBounds = new Rectangle(0, 0,
// Math.round(_image.getWidth() * _scale), Math.round(_image.getHeight() * _scale));
_rectDragBounds = Util.RectTransform(_transformToClient,
new Rectangle(0, 0, _image.getWidth(), _image.getHeight()));

if (_fConstrainedDrag)
{
Dimension sizeView = _viewport != null ? _viewport.getExtentSize() : getSize();

_ratioDragAspect = sizeView.getWidth() / sizeView.getHeight();
}
}

public void mouseReleased(MouseEvent arg0)
{
_fDragging = false;

//_UpdateImageForDrag();
_ZoomImageForDrag();

this.repaint();
}

private void _UpdateImageForDrag()
{
if (_image != null)
{
// Rectangle2D rectComponent = _RectFromDrag(),
// rectImage = new Rectangle((int)Math.round(rectComponent.getX() / _scale),
// (int)Math.round(rectComponent.getY() / _scale),
// (int)Math.round(rectComponent.getWidth() / _scale),
// (int)Math.round(rectComponent.getHeight() / _scale));
Graphics2D gfx = _image.createGraphics();

gfx.transform(_transformFromClient);
gfx.setColor(Color.WHITE);
//gfx.fill(rectImage);
gfx.fill(_RectClientFromDrag());
gfx.dispose();
}
}

private void _ZoomImageForDrag()
{
Dimension sizeView = _viewport != null ? _viewport.getExtentSize() : getSize();
Rectangle2D rectImage = _RectImageFromDrag();
double scaleX = sizeView.getWidth() / rectImage.getWidth(),
scaleY = sizeView.getHeight() / rectImage.getHeight();

setScale((float)Math.min(scaleX, scaleY));

if (_fConstrainedDrag)
{
// If we constrained the drag rectangle to match the view aspect ratio,
// then all we need to do now is scroll to the exact dragged rectangle.
// We already grabbed it in image coordinates, so now that the image
// scaling has been updated, convert back to client coordinates for use
// with the viewport.
Rectangle2D rectClientNew = Util.RectTransform(_transformToClient, rectImage);

// JViewport requires an integer-based Rectangle, so convert
Rectangle rectClientScroll = new Rectangle(
Math.round((float)rectClientNew.getX()), Math.round((float)rectClientNew.getY()),
Math.round((float)rectClientNew.getWidth()), Math.round((float)rectClientNew.getHeight()));

scrollRectToVisible(rectClientScroll);
}
else
{
// If we didn't constrain the drag rectangle, then we want to center the
// zoom rectangle in the actual view. First, get the center-point of
// the zoom rectangle from our existing drag rectangle in image coordinates,
// and convert to client coordinates
Point2D ptClientCenter = _transformToClient.transform(
new Point2D.Double(rectImage.getCenterX(), rectImage.getCenterY()), null);

// Then offset it by half the width and height of the viewport
ptClientCenter.setLocation(ptClientCenter.getX() - sizeView.getWidth() / 2,
ptClientCenter.getY() - sizeView.getHeight() / 2);

// We changed the size of the view component, and we don't want to wait
// for the revalidation to happen, so validate right now.
_viewport.validate();

_viewport.setViewPosition(new Point((int)Math.round(ptClientCenter.getX()),
(int)Math.round(ptClientCenter.getY())));
}
}

public void mouseDragged(MouseEvent arg0)
{
_ptDragCur = Util.PointConstrained(_rectDragBounds, arg0.getPoint());

if (_fConstrainedDrag)
{
Dimension sizeDrag = new Dimension((int)(
_ptDragCur.getX() - _ptDragStart.getX()),
(int)(_ptDragCur.getY() - _ptDragStart.getY()));
double ratioT = sizeDrag.getWidth() / sizeDrag.getHeight();

if (ratioT > _ratioDragAspect)
{
_ptDragCur.setLocation(
_ptDragCur.getX(),
Math.round((float)(_ptDragStart.getY() + sizeDrag.getWidth() / _ratioDragAspect)));
}
else
{
_ptDragCur.setLocation(
Math.round((float)(_ptDragStart.getX() + sizeDrag.getHeight() * _ratioDragAspect)),
_ptDragCur.getY());
}
}

this.repaint();
}

public void mouseMoved(MouseEvent arg0)
{
}

private Rectangle2D _RectClientFromDrag()
{
int x = (int)Math.min(_ptDragStart.getX(), _ptDragCur.getX()),
y = (int)Math.min(_ptDragStart.getY(), _ptDragCur.getY()),
dx = (int)Math.abs(_ptDragStart.getX() - _ptDragCur.getX()),
dy = (int)Math.abs(_ptDragStart.getY() - _ptDragCur.getY());

return new Rectangle(x, y, dx, dy);
}

private Rectangle2D _RectImageFromDrag()
{
Rectangle2D rectClient = _RectClientFromDrag();
Point2D ptMin = new Point((int)rectClient.getMinX(), (int)rectClient.getMinY()),
ptMax = new Point((int)ptMin.getX() + (int)rectClient.getWidth(), (int)ptMin.getY() + (int)rectClient.getHeight());

_transformFromClient.transform(ptMin, ptMin);
_transformFromClient.transform(ptMax, ptMax);

return new Rectangle2D.Double(ptMin.getX(), ptMin.getY(), ptMax.getX() - ptMin.getX(), ptMax.getY() - ptMin.getY());
}
}
.



Relevant Pages