Add zoom support to VirtualMediaPanel

This commit is contained in:
fourbites
2025-08-27 16:30:09 +02:00
parent 4910f7fb9d
commit 5bc8ff224c
3 changed files with 291 additions and 31 deletions
@@ -19,27 +19,26 @@
package com.threerings.media;
import java.util.ArrayList;
import java.awt.Graphics;
import java.awt.Graphics2D;
import java.awt.Point;
import java.awt.Rectangle;
import java.awt.event.MouseEvent;
import java.awt.event.MouseWheelEvent;
import java.awt.geom.AffineTransform;
import java.util.ArrayList;
import java.util.List;
import javax.swing.SwingUtilities;
import com.google.common.collect.Lists;
import com.samskivert.util.RunAnywhere;
import static com.threerings.media.Log.log;
import com.threerings.media.image.ImageUtil;
import com.threerings.media.image.Mirage;
import com.threerings.media.util.MathUtil;
import com.threerings.media.util.Pathable;
import static com.threerings.media.Log.log;
/**
* Extends the base media panel with the notion of a virtual coordinate system. All entities in
* the virtual media panel have virtual coordinates and the virtual media panel displays a window
@@ -67,6 +66,11 @@ public class VirtualMediaPanel extends MediaPanel
public VirtualMediaPanel (FrameManager framemgr)
{
super(framemgr);
addZoomListener((oldZoom, newZoom) -> {
_vbounds = _zoomManager.rescaleBounds(_vbounds, getWidth(), getHeight());
_metamgr.getRegionManager().addDirtyRegion(_vbounds);
});
}
/**
@@ -149,7 +153,8 @@ public class VirtualMediaPanel extends MediaPanel
@Override
protected void processMouseEvent (MouseEvent event)
{
event.translatePoint(_vbounds.x, _vbounds.y);
event.translatePoint(_nx, _ny);
_zoomManager.adjustMouseEvent(event, _vbounds);
super.processMouseEvent(event);
}
@@ -160,7 +165,8 @@ public class VirtualMediaPanel extends MediaPanel
@Override
protected void processMouseMotionEvent (MouseEvent event)
{
event.translatePoint(_vbounds.x, _vbounds.y);
event.translatePoint(_nx, _ny);
_zoomManager.adjustMouseEvent(event, _vbounds);
super.processMouseMotionEvent(event);
}
@@ -171,7 +177,8 @@ public class VirtualMediaPanel extends MediaPanel
@Override
protected void processMouseWheelEvent (MouseWheelEvent event)
{
event.translatePoint(_vbounds.x, _vbounds.y);
event.translatePoint(_nx, _ny);
_zoomManager.adjustMouseEvent(event, _vbounds);
super.processMouseWheelEvent(event);
}
@@ -179,7 +186,7 @@ public class VirtualMediaPanel extends MediaPanel
protected void dirtyScreenRect (Rectangle rect)
{
// translate the screen rect into happy coordinates
rect.translate(_vbounds.x, _vbounds.y);
rect.translate(_nx, _ny);
_metamgr.getRegionManager().addDirtyRegion(rect);
}
@@ -201,6 +208,7 @@ public class VirtualMediaPanel extends MediaPanel
// keep track of the size of the viewport
_vbounds.width = getWidth();
_vbounds.height = getHeight();
_vbounds = _zoomManager.scaleOnCenter(_vbounds);
// we need to obtain our absolute screen coordinates to work
// around the Windows copyArea() bug
@@ -235,16 +243,23 @@ public class VirtualMediaPanel extends MediaPanel
protected void adjustBoundsCenter ()
{
int width = getWidth(), height = getHeight();
int width = _vbounds.width, height = _vbounds.height;
// adjusts our view location to track any pathable we might be tracking
trackPathable();
// our offset is from the center of the view panel, so we need to adjust
// coordinates to account for zoom.
Point viewCenter = ZoomManager.center(_vbounds);
Point panelCenter = new Point(getWidth() / 2, getHeight() / 2);
int offsetX = viewCenter.x - panelCenter.x;
int offsetY = viewCenter.y - panelCenter.y;
// if we have a new target location, we'll need to generate dirty
// regions for the area exposed by the scrolling
if (_nx != _vbounds.x || _ny != _vbounds.y) {
if (_nx != offsetX || _ny != offsetY) {
// determine how far we'll be moving on this tick
int dx = _nx - _vbounds.x, dy = _ny - _vbounds.y;
int dx = _nx - offsetX, dy = _ny - offsetY;
// log.info("Scrolling into place [n=(" + _nx + ", " + _ny +
// "), t=(" + _vbounds.x + ", " + _vbounds.y +
@@ -254,29 +269,29 @@ public class VirtualMediaPanel extends MediaPanel
_dx = dx;
_dy = dy;
// now go ahead and update our location so that changes in between here and the call
// to paint() for this tick don't booch everything
_vbounds.x += _dx;
_vbounds.y += _dy;
// these are used to prevent the vertical strip from
// overlapping the horizontal strip
int sy = _ny, shei = height;
int sy = _vbounds.y, shei = height;
// and add invalid rectangles for the exposed areas
if (dy > 0) {
shei = Math.max(shei - dy, 0);
_metamgr.getRegionManager().invalidateRegion(_nx, _ny + height - dy, width, dy);
_metamgr.getRegionManager().invalidateRegion(_vbounds.x, _vbounds.y + height - dy, width, dy);
} else if (dy < 0) {
sy -= dy;
_metamgr.getRegionManager().invalidateRegion(_nx, _ny, width, -dy);
_metamgr.getRegionManager().invalidateRegion(_vbounds.x, _vbounds.y, width, -dy);
}
if (dx > 0) {
_metamgr.getRegionManager().invalidateRegion(_nx + width - dx, sy, dx, shei);
_metamgr.getRegionManager().invalidateRegion(_vbounds.x + width - dx, sy, dx, shei);
} else if (dx < 0) {
_metamgr.getRegionManager().invalidateRegion(_nx, sy, -dx, shei);
_metamgr.getRegionManager().invalidateRegion(_vbounds.x, sy, -dx, shei);
}
// now go ahead and update our location so that changes in between here and the call
// to paint() for this tick don't booch everything
_vbounds.x = _nx; _vbounds.y = _ny;
addObscurerDirtyRegions(false);
// let derived classes react if they so desire
@@ -313,8 +328,8 @@ public class VirtualMediaPanel extends MediaPanel
return;
}
int width = getWidth(), height = getHeight();
int nx = _vbounds.x, ny = _vbounds.y;
int width = _vbounds.width, height = _vbounds.height;
int nx = _nx, ny = _ny;
// figure out where to move
switch (_fmode) {
@@ -356,8 +371,18 @@ public class VirtualMediaPanel extends MediaPanel
@Override
protected void paint (Graphics2D gfx, Rectangle[] dirty)
{
// if we zoom too quickly, the zoomManager scale may not be in sync with the paint tick
// so we check based on the actual view bounds to be safe
double scale = getWidth() / (double) _vbounds.width;
if (scale != 1.0) {
// if our scale is not 1.0, we can't rely on copyArea(). it doesn't support non integer
// movements, so it will cause an unpleasant stutter as the rounding errors accumulate.
// TODO?: keep track of movement over multiple frames to cancel out the rounding errors.
dirty = new Rectangle[]{_vbounds};
// if we're scrolling, go ahead and do the business
if (_dx != 0 || _dy != 0) {
} else if (_dx != 0 || _dy != 0) {
int width = getWidth(), height = getHeight();
int cx = (_dx > 0) ? _dx : 0;
int cy = (_dy > 0) ? _dy : 0;
@@ -396,21 +421,28 @@ public class VirtualMediaPanel extends MediaPanel
_dx = 0; _dy = 0;
}
AffineTransform originalTransform = gfx.getTransform();
int centerX = _vbounds.x + _vbounds.width / 2;
int centerY = _vbounds.y + _vbounds.height / 2;
// translate into happy space
gfx.translate(-_vbounds.x, -_vbounds.y);
gfx.translate(getWidth() / 2, getHeight() / 2);
gfx.scale(scale, scale);
gfx.translate(-centerX, -centerY);
// now do the actual painting
super.paint(gfx, dirty);
// translate back out of happy space
gfx.translate(_vbounds.x, _vbounds.y);
gfx.setTransform(originalTransform);
}
@Override
protected void constrainToBounds (Rectangle dirty)
{
SwingUtilities.computeIntersection(
_vbounds.x, _vbounds.y, getWidth(), getHeight(), dirty);
_vbounds.x, _vbounds.y, _vbounds.width, _vbounds.height, dirty);
}
@Override
@@ -429,6 +461,38 @@ public class VirtualMediaPanel extends MediaPanel
}
}
protected void enableZoom() {
addMouseWheelListener(_zoomManager.createMouseWheelListener());
}
protected void enableZoom(double min, double max) {
enableZoom();
setZoomConstraints(min, max);
}
protected void setZoomConstraints(double min, double max) {
_zoomManager.setMinZoomLevel(min);
_zoomManager.setMaxZoomLevel(max);
}
protected void setZoomLevel(double zoom) {
_zoomManager.setZoomLevel(zoom);
}
protected double getZoomLevel() {
return _zoomManager.getZoomLevel();
}
protected void addZoomListener(ZoomManager.ZoomListener listener) {
_zoomManager.addZoomListener(listener);
}
protected void removeZoomListener(ZoomManager.ZoomListener listener) {
_zoomManager.removeZoomListener(listener);
}
protected ZoomManager _zoomManager = new ZoomManager();
/** Our viewport bounds in virtual coordinates. */
protected Rectangle _vbounds = new Rectangle();
@@ -0,0 +1,192 @@
package com.threerings.media;
import java.awt.Point;
import java.awt.Rectangle;
import java.awt.event.MouseEvent;
import java.awt.event.MouseWheelListener;
import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;
public class ZoomManager {
protected static final double ZOOM_SNAP_THRESHOLD = 0.05;
protected static final double ZOOM_OUT_FACTOR = 0.9;
protected static final double ZOOM_IN_FACTOR = 1.1;
protected double _zoomLevel = 1.0;
protected double _minZoomLevel = 0.25;
protected double _maxZoomLevel = 2.0;
public interface ZoomListener {
void zoomChanged(double oldZoom, double newZoom);
}
private final List<ZoomListener> _listeners = new CopyOnWriteArrayList<>();
public ZoomManager() {
}
public ZoomManager(double zoomLevel) {
_zoomLevel = zoomLevel;
}
public ZoomManager(double zoomLevel, double minZoomLevel, double maxZoomLevel) {
_zoomLevel = zoomLevel;
_minZoomLevel = minZoomLevel;
_maxZoomLevel = maxZoomLevel;
}
/**
* Returns the center point of the given rectangle.
*
* @param rect the rectangle to find the center of
* @return the center point of the rectangle
*/
public static Point center(Rectangle rect) {
int centerX = rect.x + rect.width / 2;
int centerY = rect.y + rect.height / 2;
return new Point(centerX, centerY);
}
/**
* Rescales the given bounds rectangle based on the current zoom level.
* Useful when the zoom level changes to update the viewport.
*
* @param viewport the rectangle to rescale
* @param width the new width
* @param height the new height
* @return a new rectangle representing the rescaled bounds
*/
public Rectangle rescaleBounds(Rectangle viewPort, int width, int height) {
Point center = center(viewPort);
int newWidth = (int) (width / _zoomLevel);
int newHeight = (int) (height / _zoomLevel);
return new Rectangle(center.x - newWidth / 2, center.y - newHeight / 2, newWidth, newHeight);
}
/**
* Scales the given viewport rectangle based on the current zoom level,
* centering the scaling on the viewport's center.
*
* @param viewPort the viewport rectangle to scale
* @return a new rectangle representing the scaled viewport
*/
public Rectangle scaleOnCenter(Rectangle viewPort) {
Point center = center(viewPort);
int newWidth = (int) (viewPort.width / _zoomLevel);
int newHeight = (int) (viewPort.height / _zoomLevel);
return new Rectangle(center.x - newWidth / 2, center.y - newHeight / 2, newWidth, newHeight);
}
/**
* Adjusts the given mouse event for the current zoom level. Uses scaled
* distance from the center of the panel
*
* @param event the mouse event to adjust
*/
public void adjustMouseEvent(MouseEvent event, Rectangle viewPort) {
Point adjustedPoint = screenToVirtual(viewPort, event.getPoint());
event.translatePoint(
adjustedPoint.x - event.getX(),
adjustedPoint.y - event.getY());
}
/**
* Adjusts the given point for the current zoom level. Zoom happens from the
* center of the viewport.
*
* @param viewPort the viewport rectangle
* @param toAdjust the point to adjust
* @return a new point representing the adjusted coordinates
*/
public Point screenToVirtual(Rectangle viewPort, Point screenPoint) {
Point center = center(viewPort);
double scale = _zoomLevel;
int virtX = (int) ((screenPoint.x - center.x) / scale + center.x);
int virtY = (int) ((screenPoint.y - center.y) / scale + center.y);
return new Point(virtX, virtY);
}
/**
* Creates a mouse wheel listener that adjusts the zoom level.
*
* @return a mouse wheel listener
*/
public MouseWheelListener createMouseWheelListener() {
return e -> {
int notches = e.getWheelRotation();
scaleZoomLevel(notches > 0 ? ZOOM_OUT_FACTOR : ZOOM_IN_FACTOR);
};
}
public double getZoomLevel() {
return _zoomLevel;
}
/**
* Scales the current zoom level by the given factor.
*
* @param scale the scale factor
* @return the actual scale factor applied after clamping to min and max
*/
public double scaleZoomLevel(double scale) {
double oldZoom = _zoomLevel;
setZoomLevel(_zoomLevel * scale);
return _zoomLevel / oldZoom;
}
/**
* Sets the current zoom level, clamping it to the min and max zoom levels.
*
* @param zoomLevel the new zoom level
*/
public void setZoomLevel(double zoomLevel) {
double oldZoom = _zoomLevel;
if (zoomLevel < _minZoomLevel) {
_zoomLevel = _minZoomLevel;
} else if (zoomLevel > _maxZoomLevel) {
_zoomLevel = _maxZoomLevel;
} else {
_zoomLevel = zoomLevel;
// if we're close to 1.0, we might as well snap to it
if (Math.abs(_zoomLevel - 1.0) < ZOOM_SNAP_THRESHOLD) {
_zoomLevel = 1.0;
}
}
if (oldZoom != _zoomLevel) {
fireZoomChanged(oldZoom, _zoomLevel);
}
}
public double getMaxZoomLevel() {
return _maxZoomLevel;
}
public void setMaxZoomLevel(double maxZoomLevel) {
_maxZoomLevel = maxZoomLevel;
}
public double getMinZoomLevel() {
return _minZoomLevel;
}
public void setMinZoomLevel(double minZoomLevel) {
_minZoomLevel = minZoomLevel;
}
public void addZoomListener(ZoomListener listener) {
_listeners.add(listener);
}
public void removeZoomListener(ZoomListener listener) {
_listeners.remove(listener);
}
private void fireZoomChanged(double oldZoom, double newZoom) {
for (ZoomListener listener : _listeners) {
listener.zoomChanged(oldZoom, newZoom);
}
}
}
@@ -118,6 +118,10 @@ public class MisoScenePanel extends VirtualMediaPanel
_resolver.start();
_resolvers.put(_ctx, _resolver);
}
addZoomListener((oldZoom, newZoom) -> {
rethink();
});
}
/**
@@ -716,7 +720,7 @@ public class MisoScenePanel extends VirtualMediaPanel
// compute the tile coordinates of our upper left screen coordinate and request a rethink
// if they've changed
MisoUtil.screenToTile(_metrics, _vbounds.x, _vbounds.y, _tcoords);
MisoUtil.screenToTile(_metrics, _nx, _ny, _tcoords);
if (_ulpos == null || !_tcoords.equals(_ulpos)) {
// if this is a forced rethink (_ulpos is null), we might delay paint as a result of
// it, but only if we queue up blocks for resolution in our rethink