package edu.hws.eck.mdbfx; import javafx.application.Platform; import javafx.scene.control.Label; import javafx.scene.control.ScrollPane; import javafx.scene.layout.BorderPane; import javafx.scene.layout.StackPane; import javafx.scene.input.MouseEvent; import javafx.scene.input.MouseButton; import javafx.scene.canvas.Canvas; import javafx.scene.canvas.GraphicsContext; import javafx.scene.paint.Color; import javafx.geometry.Rectangle2D; import javafx.geometry.Pos; import javafx.beans.property.ObjectProperty; import javafx.beans.property.SimpleObjectProperty; import java.util.Timer; import java.util.TimerTask; /** * A MandelbrotPane contains a menu bar, a MandelbrotCanvas and a status bar. The * canvas computes and displays a visualization of the Mandelbrot Set. The status bar * is a Label that is used to display information that the user might be interested * in. A mouse listener is installed on the display that enables the user to * zoom in and out on the image. Nothing is done to stop the user from zooming * in beyond the limited accuracy of numbers of type double -- when this happens, * the picture will first become "blocky" and with even further zooms will become * meaningless. * *

Mouse actions on the display: *

*/ public class MandelbrotPane extends BorderPane { /** * Constant used to identify one of the types of color palettes that can * be used for coloring the points in the image. See setPaletteType() * and setGradientPaletteType(). */ public final static int PALETTE_SPECTRUM = 0; public final static int PALETTE_PALE_SPECTRUM = 1; public final static int PALETTE_GRAYSCALE = 2; public final static int PALETTE_CYCLIC_GRAYSCALE = 3; public final static int PALETTE_GRADIENT = 4; private MandelbrotCanvas display; // The canvas where the image is drawn. private Canvas overlayCanvas; // A mostly transparent overlay where zoom box is drawn. private GraphicsContext overlayGraphics; // Graphics context for drawing on the overlay canvas. private Label statusBar; // For displaying info; placed at the bottom of the window. private StackPane displayHolder; // Holds the display and the overlay canvas. private ScrollPane scroller; // Holds the displayHolder, but only when image size is fixed. private ObjectProperty limits; // The xy-limits on the region displayed in the image. // (This holds xmin,xmax,ymin,ymax.) private Menus menubar; // The menu bar from the top of the window. private int maxIterations; // Current maximum number of iterations that will be used // in the Mandelbrot computation loop. Pixels that require // more iterations are colored black. private int paletteType; // Current palette type; one of the constants like PALETTE_SPECTRUM. private Color gradientPaletteColor1; // If palette type is PALETTE_GRADIENT, this is the gradient start color. private Color gradientPaletteColor2; // If palette type is PALETTE_GRADIENT, this is the gradient end color. private int paletteLength; // The number of colors in the palette. private int[] palette; // The colors in the palette, expressed as ARGB color codes. private double xmin, xmax, ymin, ymax; // Ranges of xy values currently visible in the image. private double xmin_requested = -2.5; // These are the values that were requested in the setLimits() private double xmax_requested = 1.1; // command. They are adjusted in the checkAspect() method to private double ymin_requested = -1.35; // fit the aspect ratio of the display, and the actual values private double ymax_requested = 1.35; // that are used for the image are stored in xmin, xmax, ymin, // and ymax. If the image changes size, the REQUESTED values // are re-applied. Initial values represent the defaults. private Rectangle2D zoomBox; // If non-null, then this rectangle is drawn on top of the // the image. The image can be zoomed into or out of this box. // the zoom box can only exist during a mouse drag operation. /** * Create a pane containing a MandelbrotCanvas, the label that * is used as a status bar, and the menu bar. */ public MandelbrotPane() { setStyle("-fx-border-color:#333; -fx-border-width:1px"); display = new MandelbrotCanvas(800,600); overlayCanvas = new Canvas(800,600); overlayGraphics = overlayCanvas.getGraphicsContext2D(); displayHolder = new StackPane(display,overlayCanvas); displayHolder.setMinSize(100,100); displayHolder.setAlignment(Pos.TOP_LEFT); statusBar = new Label(I18n.tr("Idle")); statusBar.setStyle("-fx-padding:7px; -fx-background-color:white; " + "-fx-border-color:#333; -fx-border-width:4px 0 0 0"); statusBar.setMaxWidth(Double.POSITIVE_INFINITY); setCenter(displayHolder); setBottom(statusBar); limits = new SimpleObjectProperty(); // Has to exist before creating menus. menubar = new Menus(this); setTop(menubar); overlayCanvas.setOnMousePressed( this::mousePressed ); // mouse interaction is with overlayCanvas overlayCanvas.setOnMouseDragged( this::mouseDragged ); overlayCanvas.setOnMouseReleased( this::mouseReleased ); overlayCanvas.setOnMouseMoved( this::mouseMoved ); overlayCanvas.setOnMouseExited( this::mouseExited ); paletteType = PALETTE_SPECTRUM; // defaults for palette and maxIterations paletteLength = 0; maxIterations = 250; createPalette(); checkAspect(); limits.setValue(getLimits()); /* Listen for changes to the size of displayHolder (which will be resized by this * BorderPane when the window size changes). In response, schedule a new compute * job to occur in 300 milliseconds. */ displayHolder.widthProperty().addListener( e -> startDelayedJob(300,true) ); displayHolder.heightProperty().addListener( e -> startDelayedJob(300,true) ); startDelayedJob(500, false); } /* --------------------------------------------------------------------------------- * When image size changes, the image has to be recomputed. As the user resizes the * window, the width and height of the canvas can change many times. Rather than * start a new computation each time their values change, there is a delay of * 300 milliseconds before the new computation is started. startDelayedJob() * is also used in the constructor, to avoid starting the computation before the * window has time to appear on the screen. */ private Timer resizeTimer = new Timer(); // For implementing the delay. private TimerTask resizeTask; /** * Schedules a new computation to be started after a specified number of * milliseconds. If this method is called before the time has expired, the * previously scheduled start is canceled, and a new start is scheduled with * the given delay. If the method is called many times quickly, only the * last call will actually result in a new computation starting. The method * is called with resizeFirst = true, except when it is called from the * constructor. */ private void startDelayedJob(int milliseconds, boolean resizeFirst) { synchronized(resizeTimer) { if (resizeTask != null) resizeTask.cancel(); resizeTask = new TimerTask() { public void run() { synchronized(resizeTimer) { Platform.runLater( () -> { display.stopJob(); if (resizeFirst) { display.setWidth(displayHolder.getWidth()); display.setHeight(displayHolder.getHeight()); overlayCanvas.setWidth(displayHolder.getWidth()); overlayCanvas.setHeight(displayHolder.getHeight()); checkAspect(); statusBar.setText( I18n.tr("status.imageSize", (int)displayHolder.getWidth(),(int)displayHolder.getHeight()) ); } display.startJob(maxIterations,palette,xmin,xmax,ymin,ymax); resizeTask = null; }); } } }; resizeTimer.schedule(resizeTask, milliseconds); } } //--------------------------------- Methods for use by Main and Menus ---------------------- public Menus getMenus() { return menubar; } /** * This is called by Main.java when the window is closed. It is important to cancel the * resizeTimer, since otherwise, it would stop the Java Virtual Machine from exiting. */ public void closing() { resizeTimer.cancel(); } public ObjectProperty limitsProperty() { return limits; } /** * Set the image size. If width or height is less than or equal to zero, * the size of the image will track the size of the pane. Otherwise, the * image size will be fixed; the image might not fill the window, and if it * is too big for the window, scroll bars will appear. */ public void setImageSize( int width, int height ) { if (width <= 0 || height <= 0) { if (scroller == null) return; scroller = null; setCenter(null); displayHolder = new StackPane(display,overlayCanvas); displayHolder.setMinSize(100,100); displayHolder.setAlignment(Pos.TOP_LEFT); displayHolder.widthProperty().addListener( e -> startDelayedJob(300,true) ); displayHolder.heightProperty().addListener( e -> startDelayedJob(300,true) ); setCenter(displayHolder); } else { if (scroller != null && width == (int)displayHolder.getWidth() && height == (int)displayHolder.getHeight()) { return; } setCenter(null); display.setWidth(width); display.setHeight(height); overlayCanvas.setWidth(width); overlayCanvas.setHeight(height); displayHolder = new StackPane(display,overlayCanvas); scroller = new ScrollPane(displayHolder); setCenter(scroller); checkAspect(); display.startJob(maxIterations, palette, xmin, xmax, ymin, ymax); statusBar.setText( I18n.tr("status.imageSize",width,height) ); } } public void setMaxIterations( int iters ) { if (maxIterations == iters) return; maxIterations = iters; if (paletteLength == 0) createPalette(); display.startJob(maxIterations, palette, xmin, xmax, ymin, ymax); } public int getMaxIterations() { return maxIterations; } public void setPaletteLength(int length) { if (length != 0 && length == paletteLength) return; paletteLength = length; createPalette(); display.setPalette(palette); } public int getPaletteLength() { return paletteLength; } public void setPaletteType(int type) { // type != PALETTE_GRADIENT if (paletteType == type) return; paletteType = type; createPalette(); display.setPalette(palette); gradientPaletteColor1 = null; gradientPaletteColor2 = null; } public void setGradientPalette(Color c1, Color c2) { if (paletteType == PALETTE_GRADIENT && gradientPaletteColor1.equals(c1) && gradientPaletteColor2.equals(c2)) return; paletteType = PALETTE_GRADIENT; gradientPaletteColor1 = c1; gradientPaletteColor2 = c2; createPalette(); display.setPalette(palette); } public int getPaletteType() { return paletteType; } public Color getGradientPaletteColor1() { // will be null for other gradient types return gradientPaletteColor1; } public Color getGradientPaletteColor2() { // will be null for other gradient types return gradientPaletteColor2; } /** * Sets all params back to their default values and starts a new computation. */ public void defaults() { paletteType = PALETTE_SPECTRUM; maxIterations = 250; paletteLength = 0; setLimits(-2.5,1.2,-1.35,1.35); createPalette(); display.startJob(maxIterations,palette,xmin,xmax,ymin,ymax); } /** * Sets all params to specified values and starts a new computation. * No error checking is done. This is used in Menus.java to implement * loading from an XML params file. */ public void setParams(int maxIterations, int paletteType, Color c1, Color c2, int paletteLength, double[] limits) { this.maxIterations = maxIterations; this.paletteType = paletteType; this.gradientPaletteColor1 = c1; this.gradientPaletteColor2 = c2; this.paletteLength = paletteLength; setLimits(limits[0],limits[1],limits[2],limits[3]); checkAspect(); createPalette(); display.startJob(maxIterations,palette,xmin,xmax,ymin,ymax); } /** * Change the limits on the xy-region shown in the image, possibly * readjusting them to match the aspect ratio of the display, and * start a new computation. */ public void setLimits( double xmin, double xmax, double ymin, double ymax ) { xmin_requested = xmin; xmax_requested = xmax; ymin_requested = ymin; ymax_requested = ymax; checkAspect(); limits.setValue(getLimits()); display.startJob(maxIterations,palette,this.xmin,this.xmax,this.ymin,this.ymax); } public double[] getLimits() { return new double[] { xmin, xmax, ymin, ymax }; } /** * When the xy-limits were originally set, or when they are changed with setLimits, * allDefaults, or setParams, the requested mins and maxes are saved before the * values are adjusted to match the aspect ratio of the display. This method * returns the originally requested limits. When the limits are changed by * zooming, the requested limits will be the same as the actual limits. */ public double[] getRequestedLimits() { return new double[] { xmin_requested, xmax_requested, ymin_requested, ymax_requested }; } public MandelbrotCanvas getDisplay() { return display; } //---------------------------- The rest of the file is private implementation detail ---------- /** * Adjusts the xy limits to fit the aspect ratio of the display. If the shape of * the requested region in the plane does not match the shape of the display, * then either the range of x values or the range of y values will be increased * to make the shapes match. Note that the full requested ranges are always shown. * There just might be some extra parts of the plane visible on the top and bottom * or sides. */ private void checkAspect() { xmin = xmin_requested; xmax = xmax_requested; if (xmax < xmin) { double temp = xmin; xmin = xmax; xmax = temp; } ymin = ymin_requested; ymax = ymax_requested; if (ymax < ymin) { double temp = ymax; ymax = ymin; ymin = temp; } double width = xmax - xmin; double height = ymax - ymin; double aspect = width/height; double windowAspect = (double)display.getWidth()/(double)display.getHeight(); if (aspect < windowAspect) { double newWidth = width*windowAspect/aspect; double center = (xmax + xmin)/2; xmax = center + newWidth/2; xmin = center - newWidth/2; } else if (aspect > windowAspect) { double newHeight = height*aspect/windowAspect; double center = (ymax+ymin)/2; ymax = center + newHeight/2; ymin = center - newHeight/2; } } /** * Builds the array that holds the palette colors, based on current settings. */ private void createPalette() { if (paletteLength == 0) palette = new int[maxIterations+1]; else palette = new int[paletteLength]; for (int i = 0; i < palette.length; i++) { double fraction = ((double)i)/(palette.length); Color color; switch (paletteType) { case PALETTE_GRADIENT: double r1 = gradientPaletteColor1.getRed(); double r2 = gradientPaletteColor2.getRed(); double r = Math.max(0,Math.min(1,r2*fraction + r1*(1-fraction))); double g1 = gradientPaletteColor1.getGreen(); double g2 = gradientPaletteColor2.getGreen(); double g = Math.max(0,Math.min(1,g2*fraction + g1*(1-fraction))); double b1 = gradientPaletteColor1.getBlue(); double b2 = gradientPaletteColor2.getBlue(); double b = Math.max(0,Math.min(1,b2*fraction + b1*(1-fraction))); color = Color.color(r,g,b); break; case PALETTE_SPECTRUM: color = Color.hsb(360*fraction, 1, 1); break; case PALETTE_PALE_SPECTRUM: color = Color.hsb(360*fraction, 0.6F, 1); break; case PALETTE_GRAYSCALE: color = Color.gray(0.9*fraction); break; default: // PALETTE_CYCLIC_GRAYSCALE if (fraction < 0.5) color = Color.gray(Math.max(0,Math.min(1,2*fraction))); else color = Color.gray(Math.max(0,Math.min(1,2*(1-fraction)))); break; } int argb = 0xFF; argb = argb << 8 | (int)(color.getRed()*255); argb = argb << 8 | (int)(color.getGreen()*255); argb = argb << 8 | (int)(color.getBlue()*255); palette[i] = argb; } } /** * Zoom in on or out from a point in the image. * @param x x-coordinate of the point at the center of the zoom * @param y y-coordinate of the point at the center of the zoom * @param factor magnification or shrinking factor. If factor is * greater than 1, zoom out. If factor is less than 1, zoom in. * For example, factor=0.5 shrinks the x,y ranges in the image * to half their previous size. * @param movePointToCenter if true, then the image point at pixel * position (x,y) is moved to the center pixel of the image after * the zoom; if false, the point is not moved so that the pixel * at (x,y) represents the same point after the zoom as before * and all the other points move towards or away from that one. */ public void zoom(double x, double y, double factor, boolean movePointToCenter) { double newWidth = factor*(xmax-xmin); double newHeight = factor*(ymax-ymin); double centerX = xmin + ((double)x)/display.getWidth()*(xmax-xmin); double centerY = ymax - ((double)y)/display.getHeight()*(ymax-ymin); if (movePointToCenter) { xmin = centerX-newWidth/2; xmax = centerX+newWidth/2; ymin = centerY-newHeight/2; ymax = centerY+newHeight/2; } else { double newXmin = centerX - newWidth*(centerX-xmin)/(xmax-xmin); double newYmin = centerY - newHeight*(centerY-ymin)/(ymax-ymin); xmin = newXmin; xmax = xmin + newWidth; ymin = newYmin; ymax = ymin + newHeight; } xmin_requested = xmin; xmax_requested = xmax; ymin_requested = ymin; ymax_requested = ymax; limits.setValue(getLimits()); display.startJob(maxIterations, palette, xmin, xmax, ymin, ymax); } /** * Used to draw a rectangle around a portion of the image. If the parameter is null, * then nothing is drawn (and the rectangle that was there before, if any is removed). * Otherwise, the specified rectangle will be drawn on top of the image. */ private void setZoomBox(Rectangle2D rect) { if (zoomBox != null) overlayGraphics.clearRect( zoomBox.getMinX() - 5, zoomBox.getMinY() - 5, zoomBox.getWidth() + 10, zoomBox.getWidth() + 10); zoomBox = rect; if (zoomBox != null) { overlayGraphics.setStroke(Color.WHITE); overlayGraphics.setLineWidth(4); overlayGraphics.strokeRect(zoomBox.getMinX(),zoomBox.getMinY(), zoomBox.getWidth(),zoomBox.getHeight()); overlayGraphics.setStroke(Color.BLACK); overlayGraphics.setLineWidth(2); overlayGraphics.strokeRect(zoomBox.getMinX(),zoomBox.getMinY(), zoomBox.getWidth(),zoomBox.getHeight()); } } /** * If a rectangle has been specified using the drawZoomBox, this method will zoom the * image into or out of the box. The rectangle is then discarded. If there is no * zoom box, nothing is done. * @param zoomOut if false, the part of the image inside the zoom rectangle is magnified * to fill the entire image; if true, the entire image is shrunk down to fit inside * the zoom box and new parts of the picture become visible. */ private void applyZoom(boolean zoomOut) { if (zoomBox == null) return; double x1, x2, y1, y2; // coordinates of corners of zoombox double cx, cy; // coordinates of center of zoombox double newWidth, newHeight; x1 = xmin + zoomBox.getMinX()/display.getWidth()*(xmax-xmin); x2 = xmin + zoomBox.getMaxX()/display.getWidth()*(xmax-xmin); y1 = ymax - zoomBox.getMaxY()/display.getHeight()*(ymax-ymin); y2 = ymax - zoomBox.getMinY()/display.getHeight()*(ymax-ymin); cx = (x1+x2)/2; cy = (y1+y2)/2; if (zoomOut) { // (some heavy math) double newXmin = xmin + (xmin-x1)/(x2-x1)*(xmax-xmin); double newXmax = xmin + (xmax-x1)/(x2-x1)*(xmax-xmin); double newYmin = ymin + (ymin-y1)/(y2-y1)*(ymax-ymin); double newYmax = ymin + (ymax-y1)/(y2-y1)*(ymax-ymin); display.startJob(maxIterations,palette,newXmin,newXmax,newYmin,newYmax); xmin = newXmin; xmax = newXmax; ymin = newYmin; ymax = newYmax; } else { newWidth = x2 - x1; newHeight = y2 - y1; xmin = cx-newWidth/2; xmax = cx+newWidth/2; ymin = cy-newHeight/2; ymax = cy+newHeight/2; display.startJob(maxIterations,palette,xmin,xmax,ymin,ymax); } xmin_requested = xmin; xmax_requested = xmax; ymin_requested = ymin; ymax_requested = ymax; limits.setValue(getLimits()); setZoomBox(null); } /** * Display the coordinates of the image point that corresponds * to pixel coordinates (x,y). */ private void doShowCoordsInStatusBar(double x, double y) { double width = display.getWidth(); double height = display.getHeight(); double xCoord = xmin + x/width*(xmax-xmin); double yCoord = ymax - y/height*(ymax-ymin); // The next 10 lines try to avoid more digits after the decimal // points than makes sense. If it succeeds the coordinates // that are shown should differ only in their last few digits. double diff = xmax - xmin; int scale = 4; if (diff > 0) { while (diff < 1) { scale++; diff *= 10; } } String xStr = String.format("%1." + scale + "f", xCoord); String yStr = String.format("%1." + scale + "f", yCoord); statusBar.setText(I18n.tr("status.mouseCoords",xStr,yStr)); } /*---------------------------------------------------------------------------------- * Defines the listeners that respond to user mouse actions on the display. * Note that the (x,y) coordinates for the events refer to the display, since * the listeners are registered to respond to events on the overlay canvas, which * has the same coords as the display. Mouse drags and clicks are used for zooming * the image. Dragging and mouse motion also show the current mouse coordinates * in the status bar. */ private double startX, startY; // Location of mousePressed event. private boolean dragging; // True if a drag operation is in progress. private boolean zoomOut; // True if the action will be a zoom out rather than // a zoom in. This is set to true if the shift key // or meta key is down for the mousePressed action. // (Also true if the right-mouse button is used.) private boolean moved; // During a drag operation, this becomes true if // the mouse actually moves at least a few pixels. // If so, the mouse action is interpreted as a // click rather than a drag. private boolean movePointToCenter; // True if the click point for a click // operation should be moved to the center // of the image; false if it should not // be moved. This is set in the mousePressed // routine to be true if the alt/option key // is down (or the middle mouse button is used). private void mousePressed(MouseEvent evt) { // Start a mouse drag or click operation. doShowCoordsInStatusBar(startX,startY); startX = evt.getX(); startY = evt.getY(); zoomOut = evt.isShiftDown() || evt.getButton() == MouseButton.SECONDARY; dragging = true; moved = false; movePointToCenter = evt.isAltDown() || evt.getButton() == MouseButton.MIDDLE; } private void mouseReleased(MouseEvent evt) { //Apply the zoom box to zoom in or out, if there is one, // or, if the mouse has not moved, just zoom in or out by // a factor of 2, with the current mouse location as center // of scaling. if (!dragging) return; if (moved) // If moved is true, this is a drag operation, otherwise, a click. applyZoom(zoomOut); // zoom into or out of zoom rect that user has drawn else if (zoomOut) zoom(startX,startY,2,movePointToCenter); // zoom out from point else zoom(startX,startY,0.5,movePointToCenter); // zoom in on point dragging = false; } private void mouseDragged(MouseEvent evt) { // respond to mouse drag by maybe drawing a zoom box double x = evt.getX(); double y = evt.getY(); doShowCoordsInStatusBar(x,y); if (!dragging) return; double width = Math.abs(x-startX); double height = Math.abs(y-startY); if (Math.abs(width) < 3 || Math.abs(height) < 3) { // Too close to start point to have a zoom box. setZoomBox(null); return; } moved = true; // Mouse has moved more than 2 pixels away from start position. // During a draw operation, a zoom box is drawn with one corner at the // mouse's starting position. When the mouse is released, the image // is zoomed out from this box or into this box. // The next 6 lines adjust the shape of the zoom box so that it matches // the shape of the window. This is so that zooming will use the same // magnification factor in both directions. double aspect = (double)width/height; double imageAspect = display.getWidth()/display.getHeight(); if (aspect < imageAspect) width = (width*imageAspect/aspect); else if (aspect > imageAspect) height = (height*aspect/imageAspect); // The next 9 lines compute the upper left corner of the rectangle, // so that it has one corner at the start position of the mouse; // width and height represent the size of the zoom rect. double x1,y1; if (x < startX) x1 = startX - width; else x1 = startX; if (y < startY) y1 = startY - height; else y1 = startY; Rectangle2D rect = new Rectangle2D(x1,y1,width,height); setZoomBox(rect); } private void mouseMoved(MouseEvent evt) { // When mouse moves, show coords of its location in the status bar. doShowCoordsInStatusBar(evt.getX(),evt.getY()); } private void mouseExited(MouseEvent evt) { // When mouse moves out of the display, get rid of the coordinate // display in the status bar. statusBar.setText(I18n.tr("Idle")); } } // end class MandelbrotPane