Created
June 27, 2022 06:03
-
-
Save DanskerDave/7ea13c6f3387bf3fbb5a09af07922ac2 to your computer and use it in GitHub Desktop.
PlutoExplorerExtended : a take on the well known PlutoExplorer ("Zoomable and Pannable JavaFX ImageView Example")
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
package dk.danskerdave.javafx.image; | |
import java.util.stream.IntStream; | |
import javafx.application.Application; | |
import javafx.beans.property.*; | |
import javafx.geometry.*; | |
import javafx.scene.*; | |
import javafx.scene.control.*; | |
import javafx.scene.control.Alert.AlertType; | |
import javafx.scene.image.*; | |
import javafx.scene.input.ScrollEvent; | |
import javafx.scene.layout.*; | |
import javafx.stage.Stage; | |
import javafx.util.Duration; | |
/** | |
* A JavaFX Demo of Image Zoom, Drag & Rotation. | |
*/ | |
public final class PlutoExplorerExtended extends Application { | |
private static final double HALF = 0.5d; | |
/** | |
* This is the number of Zoom-In operations required to | |
* <b><i>almost exactly</i></b> | |
* halve the size of the Viewport. | |
*/ | |
private static final int ZOOM_N = 9; // TODO try.: 1 <= ZOOM_N <= 20"-ish" | |
/** | |
* This factor guarantees that after | |
* {@link #ZOOM_N} | |
* times Zoom-In, the Viewport-size halves | |
* <b><i>almost exactly</i></b>.<br> | |
* (HALF was chosen to - perhaps? - avoid excessive Image degradation when zooming)<br> | |
* For ZOOM_N = 9 the factor value is approximately 93% | |
*/ | |
private static final double ZOOM_IN_SCALE = Math.pow(HALF, 1.0d / ZOOM_N); | |
/** | |
* Enumeration of the possible Zoom actions. | |
*/ | |
private static enum Zoom { | |
/** | |
* If Shift was pressed while | |
* Scrolling, the action | |
* will be ignored */ ZOOM_NONE( 0, Double.NaN), | |
/** = Mouse Scroll Up */ ZOOM_IN ( 1, ZOOM_IN_SCALE), | |
/** = Mouse Scroll Down */ ZOOM_OUT (-1, 1.0d / ZOOM_IN_SCALE); | |
/** 0, 1 or -1 */ private final int zoomLevelDelta; | |
/** Zoom-Factor */ private final double scale; | |
private Zoom(final int zoomLevelDelta, final double scale) { | |
this.zoomLevelDelta = zoomLevelDelta; | |
this.scale = scale; | |
} | |
private static Zoom of(final ScrollEvent scrollEvent) { | |
final double scrollAmount = scrollEvent.getDeltaY(); | |
if (scrollAmount == 0) { | |
return ZOOM_NONE; | |
} | |
if (scrollAmount > 0) { | |
return ZOOM_IN; | |
} else { | |
return ZOOM_OUT; | |
} | |
} | |
} | |
/** | |
* @see ZoomDragPane#ZoomDragPane(Image, double, double) | |
*/ | |
private static final class ZoomDragPane extends Pane { | |
private static final double MIN_PX = 10; | |
private int zoomLevel = 0; | |
private final ImageView view; | |
private final double imageWidth; | |
private final double imageHeight; | |
private final double rotation90scale; | |
/** | |
* Create a | |
* {@link Pane} | |
* container for an | |
* {@link ImageView} | |
* which encapsulates all the Zoom, Drag & Rotation logic for an Image. | |
* | |
* @param image the Image to be displayed | |
* @param prefWidth the desired width of the Pane | |
* @param prefHeight the desired height of the Pane | |
*/ | |
private ZoomDragPane(final Image image, final double prefWidth, final double prefHeight) { | |
this.setPrefSize(prefWidth, prefHeight); | |
this.setStyle ("-fx-background-color: LIGHTGREY"); | |
this.view = new ImageView(image); | |
this.view.setPreserveRatio(true); | |
this.view.setSmooth (true); | |
this.view.setCache (true); | |
this.getChildren().add(this.view); | |
this.imageWidth = image.getWidth(); | |
this.imageHeight = image.getHeight(); | |
this.view.setViewport(new Rectangle2D(0, 0, this.imageWidth, this.imageHeight)); | |
/* | |
* Unless its square, the Image must be scaled when rotated through 90 (or 270) degrees... | |
*/ | |
this.rotation90scale = Math.min(this.imageWidth, this.imageHeight) / Math.max(this.imageWidth, this.imageHeight); | |
this.view.fitWidthProperty() .bind(this.widthProperty()); | |
this.view.fitHeightProperty().bind(this.heightProperty()); | |
setMouseDraggedEventHandler(); | |
this.view.setOnScroll(e -> zoom(e)); | |
} | |
/** | |
* Drag the Viewport as the Mouse is moved. | |
*/ | |
private void setMouseDraggedEventHandler() { | |
final ObjectProperty<Point2D> mouseDown = new SimpleObjectProperty<>(); | |
/* | |
* Remember where the Mouse was in the Image when it was pressed... | |
*/ | |
this.view.setOnMousePressed(e -> mouseDown.set(imageViewToImage(e.getX(), e.getY()))); | |
/* | |
* Using the above, work out how far the Mouse has been dragged & adjust the Viewport... | |
*/ | |
this.view.setOnMouseDragged(e -> { | |
final Point2D dragPoint = imageViewToImage(e.getX(), e.getY()); | |
final Point2D dragDelta = dragPoint.subtract(mouseDown.get()); | |
final Rectangle2D viewport = this.view.getViewport(); | |
final double newX = viewport.getMinX() - dragDelta.getX(); | |
final double newY = viewport.getMinY() - dragDelta.getY(); | |
setImageViewport(newX, newY, viewport.getWidth(), viewport.getHeight()); | |
}); | |
} | |
/** | |
* Zoom Event-Handler. Zooms In or Out exactly 1 Level (if at all). | |
* <p> | |
* Note.: the X-/Y-Coordinates returned by the ScrollEvent are relative-to-the-ImageView | |
* and need to be normalised to relative-to-the-Image for the Zoom & Viewport calculations. | |
* | |
* @param scrollEvent | |
*/ | |
private void zoom(final ScrollEvent scrollEvent) { | |
final Zoom zoom = Zoom.of(scrollEvent); | |
final int zoomLevelTry = this.zoomLevel + zoom.zoomLevelDelta; | |
/* | |
* Zoomed out too far or no Zoom at all? Then there's nothing to do... | |
*/ | |
if (zoomLevelTry < 0 | |
|| zoom == Zoom.ZOOM_NONE) { | |
return; | |
} | |
/* | |
* Calculate the Viewport Size for the desired Zoom-Level... | |
*/ | |
final Dimension2D newSize = zoomCalculateViewportSize(zoomLevelTry); | |
/* | |
* If the maximum Zoom-Level has been exceeded there's nothing to do... | |
*/ | |
if (Math.min(newSize.getWidth(), newSize.getHeight()) < MIN_PX) { | |
return; | |
} | |
/* -------------------------------------------------------------- | |
* | |
* OK, the new Zoom-Level is valid: | |
* -> calculate the new Viewport X-/Y-coordinates & update the Viewport... | |
* (we Zoom in or out centred around the Pixel at the Mouse position) | |
*/ | |
this.zoomLevel = zoomLevelTry; | |
final Point2D mouseInImage = imageViewToImage(scrollEvent.getX(), scrollEvent.getY()); | |
final Point2D newLocation = zoomCalculateNewViewportXY(mouseInImage, zoom.scale); | |
/* | |
* Store the new Coordinates & Size in the Viewport... | |
*/ | |
setImageViewport(newLocation.getX(), newLocation.getY(), newSize.getWidth(), newSize.getHeight()); | |
} | |
/** | |
* To fix the Pixel @ the Mouse X-coordinate, the following is true: | |
* <br> | |
* {@code (x - newViewportMinX) / (x - currentViewportMinX) = scale} | |
* <p> | |
* The new Viewport X-coordinate is therefore given by: | |
* <br> | |
* {@code newViewportMinX = x - (x - currentViewportMinX) * scale} | |
* <p> | |
* The new Viewport Y-coordinate is calculated similarly. | |
* | |
* @param imageMouse the Mouse coordinates relative to the Image | |
* @param scale the Zoom-factor | |
* @return X-/Y-coordinate of the new Viewport<br> | |
* (which | |
* {@link #setImageViewport(double, double, double, double)} | |
* will bring into Range if necessary) | |
*/ | |
private Point2D zoomCalculateNewViewportXY(final Point2D imageMouse, final double scale) { | |
final Rectangle2D oldViewport = this.view.getViewport(); | |
final double mouseX = imageMouse.getX(); | |
final double mouseY = imageMouse.getY(); | |
final double newX = mouseX - (mouseX - oldViewport.getMinX()) * scale; | |
final double newY = mouseY - (mouseY - oldViewport.getMinY()) * scale; | |
return new Point2D(newX, newY); | |
} | |
/** | |
* Calculate the Viewport size for a particular | |
* {@code zoomLevel}. | |
* | |
* @param zoomLevel the Zoom Level | |
* @return | |
*/ | |
private Dimension2D zoomCalculateViewportSize(final int zoomLevel) { | |
final double zoomScale = Math.pow(ZOOM_IN_SCALE, zoomLevel); | |
final double newWidth = this.imageWidth * zoomScale; | |
final double newHeight = this.imageHeight * zoomScale; | |
return new Dimension2D(newWidth, newHeight); | |
} | |
/** | |
* Zoom in to the requested | |
* {@code zoomLevel}.<br> | |
* (the Viewport will be centred within the Image) | |
* | |
* @param zoomLevel | |
*/ | |
private void zoomInCentredToLevel(final int zoomLevel) { | |
this.zoomLevel = zoomLevel; | |
/* | |
* Calculate the Viewport Size for the desired Zoom-Level... | |
*/ | |
final Dimension2D newSize = zoomCalculateViewportSize(zoomLevel); | |
final double newX = (this.imageWidth - newSize.getWidth() ) / 2; | |
final double newY = (this.imageHeight - newSize.getHeight()) / 2; | |
setImageViewport(newX, newY, newSize.getWidth(), newSize.getHeight()); | |
} | |
/** | |
* Calculate Mouse coordinates within the Image based on coordinates within the ImageView. | |
* | |
* @param viewX X-coordinate of the Mouse within the ImageView | |
* @param viewY Y-coordinate of the Mouse within the ImageView | |
* | |
* @return Coordinates of the Mouse within the Image | |
*/ | |
private Point2D imageViewToImage(final double viewX, final double viewY) { | |
final Bounds boundsLocal = this.view.getBoundsInLocal(); | |
final double xProportion = viewX / boundsLocal.getWidth(); | |
final double yProportion = viewY / boundsLocal.getHeight(); | |
final Rectangle2D viewport = this.view.getViewport(); | |
final double imageX = viewport.getMinX() + xProportion * viewport.getWidth(); | |
final double imageY = viewport.getMinY() + yProportion * viewport.getHeight(); | |
return new Point2D(imageX, imageY); | |
} | |
/** | |
* Store the new Coordinates & Size in the Viewport.<br> | |
* (making sure that the Viewport remains within the Image) | |
* | |
* @param x | |
* @param y | |
* @param width | |
* @param height | |
* | |
* @see ImageView#setViewport(Rectangle2D) | |
*/ | |
private void setImageViewport(final double x, final double y, final double width, final double height) { | |
final double xMax = this.imageWidth - width; | |
final double yMax = this.imageHeight - height; | |
final double xClamp = Math.max(0, Math.min(x, xMax)); // 0 <= x <= xMax | |
final double yClamp = Math.max(0, Math.min(y, yMax)); // 0 <= y <= yMax | |
this.view.setViewport(new Rectangle2D(xClamp, yClamp, width, height)); | |
} | |
/** | |
* The Image will either be rotated or the rotation will be reset to zero. | |
* <p> | |
* Note: | |
* this rotation logic was conceived for multiples of 90 degrees.<br> | |
* It can, however, handle any angle, but the rotation90scale logic would need a touch of Pythagoras. | |
* | |
* @param relativeRotation the rotation angle, in Degrees (0 = reset to zero)<br> | |
*/ | |
private void rotateOrReset(final double relativeRotation) { | |
final double rotatePrevious = this.view.getRotate(); | |
if (relativeRotation == 0) { | |
if (rotatePrevious != 0) { | |
rotateAndScale(rotatePrevious, 0); | |
} | |
} else { | |
rotateAndScale (rotatePrevious, rotatePrevious + relativeRotation); | |
} | |
} | |
private void rotateAndScale(final double previousRotation, final double absoluteRotation) { | |
this.view.setRotate(absoluteRotation); | |
if (this.imageWidth == this.imageHeight) { | |
return; | |
} | |
final boolean multiple180previous = previousRotation % 180 == 0; | |
final boolean multiple180new = absoluteRotation % 180 == 0; | |
if (multiple180new == multiple180previous) { | |
return; | |
} | |
if (multiple180new) { | |
this.view.setScaleX(1.0d); | |
this.view.setScaleY(1.0d); | |
} else { | |
this.view.setScaleX(rotation90scale); | |
this.view.setScaleY(rotation90scale); | |
} | |
} | |
} | |
private static final String TITLE = "Pluto Explorer"; | |
private static final String IMAGE_CREDIT_URL = "http://www.nasa.gov/image-feature/global-mosaic-of-pluto-in-true-color"; | |
private static final String IMAGE_URL = "https://www.nasa.gov/sites/default/files/thumbnails/image/global-mosaic-of-pluto-in-true-color.jpg"; | |
@Override | |
public void start(final Stage primaryStage) { | |
final Image image = new Image(IMAGE_URL); | |
final double paneWidth = image.getWidth(); | |
final ZoomDragPane pane = new ZoomDragPane(image, paneWidth, paneWidth * image.getHeight() / image.getWidth()); | |
final Hyperlink link = new Hyperlink("Image Credit: NASA/JHUAPL/SwRI"); | |
; link.setOnAction(e -> getHostServices().showDocument(IMAGE_CREDIT_URL)); | |
; link.setPadding (new Insets(10)); | |
; link.setTooltip (new Tooltip(IMAGE_CREDIT_URL)); | |
final HBox buttons = createButtons(pane); | |
final VBox root = new VBox(link, pane, buttons); | |
; root.setFillWidth(true); | |
VBox.setVgrow(pane, Priority.ALWAYS); | |
installTooltip(pane); | |
pane.setOnMouseClicked(e -> { | |
if (e.getClickCount() == 2) { | |
final Alert alert = new Alert(AlertType.INFORMATION); | |
; alert.setTitle (TITLE); | |
; alert.setHeaderText ("Double-Click Dialogue"); | |
; alert.setContentText("Press OK or Enter/Return"); | |
; alert.showAndWait(); | |
} | |
}); | |
primaryStage.setScene(new Scene(root)); | |
primaryStage.setTitle(TITLE); | |
primaryStage.show(); | |
} | |
/** | |
* Installs a once-only Tooltip centred over the Image. | |
* | |
* @param zoomDragPane | |
*/ | |
private static void installTooltip(final ZoomDragPane zoomDragPane) { | |
/* | |
* Implementation note.: | |
* hijacking the setOnShowing(...) method & performing a show(...) there | |
* seems to be the only way of accurately positioning a Tooltip. | |
* Even though the Tooltip is only displayed once, the setOnShowing(...) method | |
* is invoked 3 times, each time with a different Tooltip size, | |
* the last of which seems to be final & is (apart from the 18 Pixel discrepancy) correct. | |
*/ | |
final Tooltip toolTip = new Tooltip("Scroll to zoom, drag to pan"); | |
; toolTip.setOpacity (0.75); | |
; toolTip.setShowDelay (Duration.seconds(0.5)); | |
; toolTip.setShowDuration(Duration.seconds(9)); | |
; toolTip.setOnShown (e -> Tooltip.uninstall(zoomDragPane, toolTip)); | |
; toolTip.setOnShowing (e -> { | |
final Node view = zoomDragPane.view; | |
final Bounds viewBoundsLocal = view.getBoundsInLocal(); | |
final Bounds viewBounds = view.localToScreen(viewBoundsLocal); | |
final int toolTipFudge = 18; // Fudge because Tooltip claims to be larger than it really is | |
final double toolTipWidth = toolTip.getWidth() - toolTipFudge; | |
final double toolTipHeight = toolTip.getHeight() - toolTipFudge; | |
final double toolTipOffsetX = (viewBounds.getWidth() - toolTipWidth ) / 2; | |
final double toolTipOffsetY = (viewBounds.getHeight() - toolTipHeight) / 2; | |
toolTip.show(zoomDragPane, viewBounds.getMinX() + toolTipOffsetX, viewBounds.getMinY() + toolTipOffsetY); | |
}); | |
Tooltip.install (zoomDragPane, toolTip); | |
} | |
/** | |
* Creates a Bar with Zoom Buttons & Rotation CheckBox. | |
* | |
* @param zoomDragPane | |
* @return | |
*/ | |
private static HBox createButtons(final ZoomDragPane zoomDragPane) { | |
final char DEGREE = '\u00B0'; | |
final CheckBox rotate = new CheckBox("Rotate 90" + DEGREE + " (Toggle)"); | |
; rotate.setOnAction(e -> zoomDragPane.rotateOrReset(rotate.isSelected() ? 90 : 0)); | |
; rotate.setPadding(new Insets(0, 0, 0, 50)); | |
final HBox toolBox = new HBox(10); | |
; toolBox.setAlignment(Pos.CENTER); | |
; toolBox.setPadding(new Insets(10)); | |
IntStream.range(0, 3).forEach(n -> { | |
final Button zoom = new Button("Zoom " + ((int) Math.pow(2, n)) + "00%"); | |
; zoom.setOnAction(e -> zoomDragPane.zoomInCentredToLevel(ZOOM_N * n)); | |
toolBox.getChildren().add(zoom); | |
}); | |
toolBox .getChildren().add(rotate); | |
return toolBox; | |
} | |
public static void main(final String[] args) { | |
launch(args); | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment