package de.eisfeldj.augendiagnosefx.fxelements; import java.util.List; import com.sun.javafx.scene.NodeEventDispatcher; import de.eisfeldj.augendiagnosefx.util.DialogUtil; import de.eisfeldj.augendiagnosefx.util.DialogUtil.ProgressDialog; import de.eisfeldj.augendiagnosefx.util.ResourceConstants; import de.eisfeldj.augendiagnosefx.util.imagefile.EyePhoto; import de.eisfeldj.augendiagnosefx.util.imagefile.ImageUtil.Resolution; import de.eisfeldj.augendiagnosefx.util.imagefile.JpegMetadata; import javafx.application.Platform; import javafx.beans.property.DoubleProperty; import javafx.beans.property.SimpleDoubleProperty; import javafx.beans.value.ChangeListener; import javafx.beans.value.ObservableValue; import javafx.event.Event; import javafx.event.EventDispatchChain; import javafx.event.EventDispatcher; import javafx.event.EventHandler; import javafx.scene.control.ScrollPane; import javafx.scene.image.Image; import javafx.scene.image.ImageView; import javafx.scene.input.MouseEvent; import javafx.scene.input.ScrollEvent; import javafx.scene.input.TouchEvent; import javafx.scene.input.TouchPoint; import javafx.scene.input.ZoomEvent; import javafx.scene.layout.BorderPane; /** * Pane containing an image that can be resized. */ public class SizableImageView extends ScrollPane { /** * The zoom factor to be applied for each zoom event. * * <p>(480th root of 2 means that 12 wheel turns of 40 will result in size factor 2.) */ private static final double ZOOM_FACTOR = 1.0014450997779993488675056142818; /** * The x/y values representing the center of the image. */ private static final float IMAGE_CENTER = 0.5f; /** * The zoom factor. */ private final DoubleProperty mZoomProperty = new SimpleDoubleProperty(1000); /** * The mouse X position. */ private final DoubleProperty mMouseXProperty = new SimpleDoubleProperty(); /** * The mouse Y position. */ private final DoubleProperty mMouseYProperty = new SimpleDoubleProperty(); /** * The touch X position. */ private final DoubleProperty mTouchXProperty = new SimpleDoubleProperty(); /** * The touch Y position. */ private final DoubleProperty mTouchYProperty = new SimpleDoubleProperty(); /** * Flag indicating if the view is initialized (and image is loaded). */ private boolean mIsInitialized = false; /** * The number of touch points on touch screen. */ private int mTouchCount = 0; public final boolean isInitialized() { return mIsInitialized; } /** * The displayed ImageView. */ private ImageView mImageView; public final ImageView getImageView() { return mImageView; } /** * The displayed eye photo. */ private EyePhoto mEyePhoto; protected final EyePhoto getEyePhoto() { return mEyePhoto; } /** * X Location of the view center on the image. */ private double mCenterX; /** * Y Location of the view center on the image. */ private double mCenterY; /** * Constructor without initialization of image. */ public SizableImageView() { mImageView = new ImageView(); mImageView.setPreserveRatio(true); setContent(new BorderPane(mImageView)); setPannable(true); setFitToHeight(true); setFitToWidth(true); setHbarPolicy(ScrollBarPolicy.NEVER); setVbarPolicy(ScrollBarPolicy.NEVER); final NodeEventDispatcher defaultEventDispatcher = (NodeEventDispatcher) getEventDispatcher(); setEventDispatcher(new EventDispatcher() { @Override public Event dispatchEvent(final Event event, final EventDispatchChain tail) { if (event instanceof ScrollEvent) { handleScrollEvent((ScrollEvent) event); return event; } else if (event instanceof ZoomEvent) { handleZoomEvent((ZoomEvent) event); return event; } else if (event instanceof TouchEvent) { handleTouchEvent((TouchEvent) event); return event; } else { return defaultEventDispatcher.dispatchEvent(event, tail); } } }); setOnMouseMoved(new EventHandler<MouseEvent>() { @Override public void handle(final MouseEvent event) { mMouseXProperty.set(event.getX()); mMouseYProperty.set(event.getY()); } }); } /** * Apply a zoom to the image. * * @param deltaZoomFactor * the change of the zoom factor. */ private void zoomImage(final double deltaZoomFactor) { double xCenter = mTouchCount > 1 ? mTouchXProperty.get() : mMouseXProperty.get(); double yCenter = mTouchCount > 1 ? mTouchYProperty.get() : mMouseYProperty.get(); // Original size of the image. double sourceWidth = mZoomProperty.get() * mImageView.getImage().getWidth(); double sourceHeight = mZoomProperty.get() * mImageView.getImage().getHeight(); multiplyZoomProperty(deltaZoomFactor); // Old values of the scrollbars. double oldHvalue = getHvalue(); double oldVvalue = getVvalue(); // Image pixels outside the visible area which need to be scrolled. double preScrollXFactor = Math.max(0, sourceWidth - getWidth()); double preScrollYFactor = Math.max(0, sourceHeight - getHeight()); // Relative position of the mouse in the image. double mouseXPosition = (xCenter + preScrollXFactor * oldHvalue) / sourceWidth; double mouseYPosition = (yCenter + preScrollYFactor * oldVvalue) / sourceHeight; // Target size of the image. double targetWidth = mZoomProperty.get() * mImageView.getImage().getWidth(); double targetHeight = mZoomProperty.get() * mImageView.getImage().getHeight(); // Image pixels outside the visible area which need to be scrolled. double postScrollXFactor = Math.max(0, targetWidth - getWidth()); double postScrollYFactor = Math.max(0, targetHeight - getHeight()); // New scrollbar positions keeping the mouse position. double newHvalue = postScrollXFactor > 0 // STORE_PROPERTY ? ((mouseXPosition * targetWidth) - xCenter) / postScrollXFactor : oldHvalue; double newVvalue = postScrollYFactor > 0 // STORE_PROPERTY ? ((mouseYPosition * targetHeight) - yCenter) / postScrollYFactor : oldVvalue; mImageView.setFitWidth(targetWidth); mImageView.setFitHeight(targetHeight); // Layout needs to be done now so that default scrollbar position is applied. layout(); setHvalue(newHvalue); setVvalue(newVvalue); } /** * Process a scroll event. * * @param event * The scroll event. */ private void handleScrollEvent(final ScrollEvent event) { if (mTouchCount > 0) { // Do not handle scroll events on touch pad. return; } zoomImage(Math.pow(ZOOM_FACTOR, event.getDeltaY())); } /** * Handle a zoom event. * * @param event * The zoom event. */ private void handleZoomEvent(final ZoomEvent event) { if (!Double.isNaN(event.getZoomFactor())) { zoomImage(event.getZoomFactor()); } } /** * Handle a touch event. * * @param event * The touch event. */ private void handleTouchEvent(final TouchEvent event) { if (event.getEventType().equals(TouchEvent.TOUCH_PRESSED)) { mTouchCount = event.getTouchCount(); } else if (event.getEventType().equals(TouchEvent.TOUCH_RELEASED)) { // getTouchCount gives the number of touch points before the release. mTouchCount = event.getTouchCount() - 1; } if (mTouchCount > 1) { List<TouchPoint> touchPoints = event.getTouchPoints(); double sumX = 0; double sumY = 0; for (TouchPoint point : touchPoints) { sumX += point.getX(); sumY += point.getY(); } mTouchXProperty.set(sumX / touchPoints.size()); mTouchYProperty.set(sumY / touchPoints.size()); } } /** * Set the eye photo displayed by this class. * * @param eyePhoto * The eye photo. */ public final void setEyePhoto(final EyePhoto eyePhoto) { mIsInitialized = false; this.mEyePhoto = eyePhoto; Image image = eyePhoto.getImage(Resolution.NORMAL); if (image.getProgress() == 1) { // image is already loaded from the start. Platform.runLater(new Runnable() { @Override public void run() { displayImage(image); } }); return; } else { ProgressDialog dialog = DialogUtil .displayProgressDialog(ResourceConstants.MESSAGE_PROGRESS_LOADING_PHOTO, eyePhoto.getFilename()); image.progressProperty().addListener(new ChangeListener<Number>() { @Override public void changed(final ObservableValue<? extends Number> observable, final Number oldValue, final Number newValue) { dialog.setProgress(newValue.doubleValue()); if (newValue.doubleValue() == 1) { Platform.runLater(new Runnable() { @Override public void run() { displayImage(image); dialog.close(); } }); } } }); } // Size the image only after this pane is sized heightProperty().addListener(new ChangeListener<Number>() { @Override public void changed(final ObservableValue<? extends Number> observable, final Number oldValue, final Number newValue) { synchronized (mImageView) { // Initialization after window is sized and image is loaded. if (mImageView.getImage() != null && !mIsInitialized) { doInitialScaling(eyePhoto.getImageMetadata()); } } heightProperty().removeListener(this); } }); } /** * Display the image after it is loaded. * * @param image * The fully loaded image. */ // OVERRIDABLE protected void displayImage(final Image image) { mImageView.setImage(image); synchronized (mImageView) { // Initialization after window is sized and image is loaded. if (getHeight() > 0 && !mIsInitialized) { doInitialScaling(mEyePhoto.getImageMetadata()); } } } /** * Display a pre-loaded image generated from an eye photo. * * @param metadata * The metadata to be used for scaling. * @param image * The pre-loaded image. */ // OVERRIDABLE public void setImage(final JpegMetadata metadata, final Image image) { mImageView.setImage(image); synchronized (mImageView) { // Initialization after window is sized and image is loaded. if (getHeight() > 0 && !mIsInitialized) { doInitialScaling(metadata); } } // Size the image only after this pane is sized heightProperty().addListener(new ChangeListener<Number>() { @Override public void changed(final ObservableValue<? extends Number> observable, final Number oldValue, final Number newValue) { synchronized (mImageView) { // Initialization after window is sized and image is loaded. if (mImageView.getImage() != null && !mIsInitialized) { synchronized (mImageView) { // Initialization after window is sized and image is loaded. if (getHeight() > 0 && !mIsInitialized) { doInitialScaling(metadata); } } } } heightProperty().removeListener(this); } }); } /** * Do the initial scaling of the image. * * @param metadata * The metadata by which to do the scaling. */ private void doInitialScaling(final JpegMetadata metadata) { if (metadata != null && metadata.hasViewPosition()) { mZoomProperty.set(getDefaultScaleFactor() * metadata.getZoomFactor()); } else if (metadata != null && metadata.hasOverlayPosition()) { mZoomProperty.set(Math.min(getWidth(), getHeight()) / Math.max(mImageView.getImage().getWidth(), mImageView.getImage().getHeight()) / metadata.getOverlayScaleFactor()); } else { mZoomProperty.set(getDefaultScaleFactor()); } mImageView.setFitWidth(mZoomProperty.get() * mImageView.getImage().getWidth()); mImageView.setFitHeight(mZoomProperty.get() * mImageView.getImage().getHeight()); layout(); if (metadata != null && (metadata.hasViewPosition() || metadata.hasOverlayPosition())) { float xCenter; float yCenter; if (metadata.hasViewPosition()) { xCenter = metadata.getXPosition(); yCenter = metadata.getYPosition(); } else { xCenter = metadata.getXCenter(); yCenter = metadata.getYCenter(); } ScrollPosition scrollPosition = convertMetadataPositionToScrollPosition( new MetadataPosition(xCenter, yCenter)); setHvalue(scrollPosition.mHValue); setVvalue(scrollPosition.mVValue); } mIsInitialized = true; } /** * Store image position for later retrieval. Can be used to keep view center if the view size changes. */ public final void storePosition() { if (mImageView == null || mImageView.getImage() == null) { return; } // Size of the image. double imageWidth = mZoomProperty.get() * mImageView.getImage().getWidth(); double imageHeight = mZoomProperty.get() * mImageView.getImage().getHeight(); // Image pixels outside the visible area which need to be scrolled. double scrollXFactor = Math.max(0, imageWidth - getWidth()); double scrollYFactor = Math.max(0, imageHeight - getHeight()); // Calculate position of pane center in the image mCenterX = scrollXFactor > 0 ? (getWidth() / 2 + scrollXFactor * getHvalue()) / imageWidth : 0.5; // MAGIC_NUMBER mCenterY = scrollYFactor > 0 ? (getHeight() / 2 + scrollYFactor * getVvalue()) / imageHeight : 0.5; // MAGIC_NUMBER } /** * Retrieve image position from the position stored with storePosition(). */ public final void retrievePosition() { if (mImageView == null || mImageView.getImage() == null) { return; } // Size of the image. double imageWidth = mZoomProperty.get() * mImageView.getImage().getWidth(); double imageHeight = mZoomProperty.get() * mImageView.getImage().getHeight(); // Image pixels outside the visible area which need to be scrolled. double scrollXFactor = Math.max(0, imageWidth - getWidth()); double scrollYFactor = Math.max(0, imageHeight - getHeight()); // Move scroll position to put center back. if (scrollXFactor > 0) { setHvalue((mCenterX * imageWidth - getWidth() / 2) / scrollXFactor); } if (scrollYFactor > 0) { setVvalue((mCenterY * imageHeight - getHeight() / 2) / scrollYFactor); } } /** * Multiply the zoom property by the given factor. * * @param factor * The factor. */ protected final void multiplyZoomProperty(final double factor) { mZoomProperty.set(mZoomProperty.get() * factor); } /** * Retrieve the default scale factor of the image. * * @return The default scale factor that fits the image into the view. */ private double getDefaultScaleFactor() { return Math.min(getWidth() / mImageView.getImage().getWidth(), getHeight() / mImageView.getImage().getHeight()); } /** * Helper method to retrieve the position of the image within the view. * * @return the position within the image */ public final MetadataPosition getPosition() { MetadataPosition metadataPosition = convertScrollPositionToMetadataPosition( new ScrollPosition(getHvalue(), getVvalue())); metadataPosition.mZoom = (float) (mZoomProperty.get() / getDefaultScaleFactor()); return metadataPosition; } /** * Convert coordinates like stored in metadata to coordinates like used in the ScrollPane. * * @param metadataPosition * The coordinates like stored in metadata. * @return The coordinates like used in ScrollPane. */ private ScrollPosition convertMetadataPositionToScrollPosition(final MetadataPosition metadataPosition) { // Size of the image. double imageWidth = mZoomProperty.get() * mImageView.getImage().getWidth(); double imageHeight = mZoomProperty.get() * mImageView.getImage().getHeight(); // Image pixels outside the visible area which need to be scrolled. double scrollXFactor = Math.max(0, imageWidth - getWidth()); double scrollYFactor = Math.max(0, imageHeight - getHeight()); double hValue = scrollXFactor > 0 ? (metadataPosition.mXCenter * imageWidth - getWidth() / 2) / scrollXFactor : 1; double vValue = scrollYFactor > 0 ? (metadataPosition.mYCenter * imageHeight - getHeight() / 2) / scrollYFactor : 1; return new ScrollPosition(hValue, vValue); } /** * Convert coordinates like used in the ScrollPane to coordinates like stored in metadataused in the ScrollPane. * * @param scrollPosition * The coordinates like used in ScrollPane. * @return The coordinates like stored in metadata. */ private MetadataPosition convertScrollPositionToMetadataPosition(final ScrollPosition scrollPosition) { // Size of the image. double imageWidth = mZoomProperty.get() * mImageView.getImage().getWidth(); double imageHeight = mZoomProperty.get() * mImageView.getImage().getHeight(); // Image pixels outside the visible area which need to be scrolled. double scrollXFactor = Math.max(0, imageWidth - getWidth()); double scrollYFactor = Math.max(0, imageHeight - getHeight()); double xCenter = scrollXFactor > 0 ? (scrollXFactor * scrollPosition.mHValue + getWidth() / 2) / imageWidth : IMAGE_CENTER; double yCenter = scrollYFactor > 0 ? (scrollYFactor * scrollPosition.mVValue + getHeight() / 2) / imageHeight : IMAGE_CENTER; return new MetadataPosition((float) xCenter, (float) yCenter); } /** * Clone the contents from another instance. * * @param view The other instance. */ public void cloneContents(final SizableImageView view) { mEyePhoto = view.mEyePhoto; } /** * Class holding zoom and position as stored in the metadata. */ public static class MetadataPosition { // PUBLIC_FIELDS:START // JAVADOC:OFF public float mXCenter; public float mYCenter; public float mZoom; // JAVADOC:ON // PUBLIC_FIELDS:END /** * Initialize a MetadataPosition with coordinate values. * * @param xCenter * The x position of the center * @param yCenter * The y position of the center */ public MetadataPosition(final float xCenter, final float yCenter) { this.mXCenter = xCenter; this.mYCenter = yCenter; } } /** * Class holding zoom and position as used in the scrollPane. */ public static class ScrollPosition { // PUBLIC_FIELDS:START // JAVADOC:OFF public double mHValue; public double mVValue; // JAVADOC:ON // PUBLIC_FIELDS:END /** * Initialize a ScrollPosition with scrollbar values. * * @param hValue * The horizontal value * @param vValue * The vertical value */ public ScrollPosition(final double hValue, final double vValue) { this.mHValue = hValue; this.mVValue = vValue; } } }