/** * Copyright (C) 2001-2017 by RapidMiner and the contributors * * Complete list of developers available at our web site: * * http://rapidminer.com * * This program is free software: you can redistribute it and/or modify it under the terms of the * GNU Affero General Public License as published by the Free Software Foundation, either version 3 * of the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without * even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License along with this program. * If not, see http://www.gnu.org/licenses/. */ package com.rapidminer.gui.flow.processrendering.annotations; import java.awt.AlphaComposite; import java.awt.BasicStroke; import java.awt.Color; import java.awt.Dimension; import java.awt.GradientPaint; import java.awt.Graphics2D; import java.awt.Image; import java.awt.RenderingHints; import java.awt.Stroke; import java.awt.geom.Line2D; import java.awt.geom.Rectangle2D; import java.awt.image.BufferedImage; import java.lang.ref.WeakReference; import java.util.HashMap; import java.util.Map; import java.util.UUID; import javax.swing.JEditorPane; import com.rapidminer.gui.flow.processrendering.annotations.model.AnnotationDragHelper; import com.rapidminer.gui.flow.processrendering.annotations.model.AnnotationResizeHelper; import com.rapidminer.gui.flow.processrendering.annotations.model.AnnotationResizeHelper.ResizeDirection; import com.rapidminer.gui.flow.processrendering.annotations.model.AnnotationsModel; import com.rapidminer.gui.flow.processrendering.annotations.model.OperatorAnnotation; import com.rapidminer.gui.flow.processrendering.annotations.model.ProcessAnnotation; import com.rapidminer.gui.flow.processrendering.annotations.model.WorkflowAnnotation; import com.rapidminer.gui.flow.processrendering.annotations.model.WorkflowAnnotations; import com.rapidminer.gui.flow.processrendering.annotations.style.AnnotationColor; import com.rapidminer.gui.flow.processrendering.draw.ProcessDrawer; import com.rapidminer.gui.flow.processrendering.model.ProcessRendererModel; import com.rapidminer.operator.Operator; /** * This class does the actual Java2D drawing for all {@link WorkflowAnnotation}s of the currently * displayed process. * * @author Marco Boeck * @since 6.4.0 * */ public final class AnnotationDrawer { /** the color used to highlight valid drag targets */ private static final Color DRAG_LINK_COLOR = ProcessDrawer.OPERATOR_BORDER_COLOR_SELECTED; /** the color used to indicate invalid drag targets */ private static final Color GRAY_OUT = new Color(255, 255, 255, 100); /** the stroke which is used to highlight drag targets */ private static final Stroke DRAG_BORDER_STROKE = new BasicStroke(2f); /** the editor pane which displays all annotations */ private final JEditorPane pane; /** the model instance */ private final AnnotationsModel model; /** the process renderer model instance */ private final ProcessRendererModel rendererModel; /** this map caches images for workflow annotations for faster drawing */ private final Map<UUID, WeakReference<Image>> displayCache; /** * this map stores an id of the cached image for a workflow annotation to identify old images */ private final Map<UUID, Integer> cachedID; /** * Creates a new drawer for the specified model and decorator. * * @param model * the model containing all relevant drawing data * @param rendererModel * the process renderer model */ public AnnotationDrawer(final AnnotationsModel model, final ProcessRendererModel rendererModel) { this.model = model; this.rendererModel = rendererModel; this.displayCache = new HashMap<>(); this.cachedID = new HashMap<>(); pane = new JEditorPane("text/html", ""); pane.setBorder(null); pane.setOpaque(false); } /** * Draws the given annotation. * * @param anno * the annotation to draw * @param g2 * the graphics context to draw upon * @param printing * if {@code true} we are printing instead of drawing to the screen */ public void drawAnnotation(final WorkflowAnnotation anno, final Graphics2D g2, final boolean printing) { // do basic interpolation when zooming or on high dpi screens g2.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BILINEAR); Rectangle2D loc = anno.getLocation(); AnnotationColor col = anno.getStyle().getAnnotationColor(); // skip if not in current clip if (g2.getClip() != null && !g2.getClip().intersects(anno.getLocation())) { return; } // draw drag indicators if needed if (model.getDragged() != null && model.getDragged().getDraggedAnnotation().equals(anno)) { drawAnnoDragIndicators(g2, anno, loc, printing); shadowOperatorsWhileDragging(g2, anno, printing); } // background coloring because editor image is transparent if (anno.equals(model.getHovered()) && !isProcessInteractionHappening(rendererModel)) { g2.setColor(col.getColorHighlight()); } else { g2.setColor(col.getColor()); } if (anno.equals(model.getSelected())) { // during resize and drag make background more transparent to improve usability if (model.getDragged() != null && model.getDragged().isDragInProgress() || model.getResized() != null && model.getResized().isResizeInProgress()) { g2.setColor(col.getColorTransparent()); } else { g2.setColor(col.getColorHighlight()); } } // draw background g2.fillRect((int) loc.getX(), (int) loc.getY(), (int) loc.getWidth(), (int) loc.getHeight()); // draw text by drawing image of JEditorPane // check if we can paint annotation from cache int cacheId = createCacheId(anno); WeakReference<Image> cachedImgRef = displayCache.get(anno.getId()); Image cachedImage = cachedImgRef != null ? cachedImgRef.get() : null; if (cachedID.get(anno.getId()) == null || cachedID.get(anno.getId()) != cacheId || cachedImage == null) { // not in cache/not up to date, refresh cache cachedImage = cacheAnnotationImage(anno, cacheId); } // if printing, use slow but high-quality rendering (supporting SVG) if (printing) { printAnnotationFromEditor(anno, g2); } else { // not printing, use fast image cache g2.drawImage(cachedImage, (int) loc.getX(), (int) loc.getY(), null); } cachedImage = null; // border of the annotation if (anno.equals(model.getSelected())) { // actual border Stroke prevStroke = g2.getStroke(); if (model.getDragged() != null && model.getDragged().getHoveredOperator() != null) { g2.setColor(DRAG_LINK_COLOR); g2.setStroke(DRAG_BORDER_STROKE); } else { g2.setColor(ProcessDrawer.OPERATOR_BORDER_COLOR_SELECTED); } g2.drawRect((int) loc.getX(), (int) loc.getY(), (int) loc.getWidth(), (int) loc.getHeight() - 1); g2.setStroke(prevStroke); // resize indicators either if process anno or if drag in progress but not over target if (anno instanceof ProcessAnnotation || model.getDragged() != null && model.getDragged().getHoveredOperator() == null && model.getDragged().isUnsnapped()) { drawProcessAnnoResizeIndicators(g2, loc, printing); } } else if (anno.equals(model.getHovered())) { if (!isProcessInteractionHappening(rendererModel)) { // for transparent color, draw hover border if (anno.getStyle().getAnnotationColor() == AnnotationColor.TRANSPARENT) { g2.setColor(Color.LIGHT_GRAY); g2.drawRect((int) loc.getX(), (int) loc.getY(), (int) loc.getWidth() - 1, (int) loc.getHeight() - 1); } } } // overflow indicator if needed if (anno.isOverflowing() && model.getResized() == null) { drawOverflowIndicator(anno, g2, loc, printing); } // shadow this annotation if another one is dragged and this one is attached to an operator if (model.getDragged() != null && anno instanceof OperatorAnnotation && !model.getDragged().getDraggedAnnotation().equals(anno) && model.getDragged().isDragInProgress() && model.getDragged().isUnsnapped()) { overshadowRect(loc, g2); } } /** * Resets the drawer and its caches. */ public void reset() { displayCache.clear(); cachedID.clear(); } /** * Draws the resize indicators for {@link ProcessAnnotation}s. * * @param g * the graphics context to draw upon * @param loc * the location of the annotation * @param printing * if we are currently printing */ private void drawProcessAnnoResizeIndicators(final Graphics2D g, final Rectangle2D loc, final boolean printing) { if (printing) { // never draw them for printing return; } if (model.getDragged() != null && model.getDragged().getHoveredOperator() != null) { // don't draw them while hovering over an operator to indicate snap return; } Graphics2D g2 = (Graphics2D) g.create(); Line2D line; int offset = 3; int startValue = 5; int distance = 4; int indicatorOffsetXMax = 15; int indicatorOffsetXMin = 5; int indicatorOffsetYMax = 15; int indicatorOffsetYMin = 5; AnnotationResizeHelper resized = model.getResized(); ResizeDirection resizeDirection = model.getHoveredResizeDirection(); // top right if (resizeDirection == ResizeDirection.TOP_RIGHT || resized != null && resized.getDirection() == ResizeDirection.TOP_RIGHT) { g2.setColor(Color.BLACK); line = new Line2D.Double(loc.getMaxX() - offset, loc.getY() + startValue + distance * 2, loc.getMaxX() - (startValue + distance * 2), loc.getY() + offset); g2.draw(line); line = new Line2D.Double(loc.getMaxX() - offset, loc.getY() + startValue + distance, loc.getMaxX() - (startValue + distance), loc.getY() + offset); g2.draw(line); line = new Line2D.Double(loc.getMaxX() - offset, loc.getY() + startValue, loc.getMaxX() - startValue, loc.getY() + offset); g2.draw(line); } else if (resized == null) { g2.setColor(Color.GRAY); line = new Line2D.Double(loc.getMaxX() - indicatorOffsetXMax, loc.getY() + indicatorOffsetYMin - 1, loc.getMaxX() - indicatorOffsetXMin, loc.getY() + indicatorOffsetYMin - 1); g2.draw(line); line = new Line2D.Double(loc.getMaxX() - indicatorOffsetXMin, loc.getY() + indicatorOffsetYMin - 1, loc.getMaxX() - indicatorOffsetXMin, loc.getY() + indicatorOffsetYMax - 1); g2.draw(line); } // bottom right if (resizeDirection == ResizeDirection.BOTTOM_RIGHT || resized != null && resized.getDirection() == ResizeDirection.BOTTOM_RIGHT) { g2.setColor(Color.BLACK); line = new Line2D.Double(loc.getMaxX() - offset, loc.getMaxY() - (startValue + distance * 2), loc.getMaxX() - (startValue + distance * 2), loc.getMaxY() - offset); g2.draw(line); line = new Line2D.Double(loc.getMaxX() - offset, loc.getMaxY() - (startValue + distance), loc.getMaxX() - (startValue + distance), loc.getMaxY() - offset); g2.draw(line); line = new Line2D.Double(loc.getMaxX() - offset, loc.getMaxY() - startValue, loc.getMaxX() - startValue, loc.getMaxY() - offset); g2.draw(line); } else if (resized == null) { g2.setColor(Color.GRAY); line = new Line2D.Double(loc.getMaxX() - indicatorOffsetXMax, loc.getMaxY() - indicatorOffsetYMin, loc.getMaxX() - indicatorOffsetXMin, loc.getMaxY() - indicatorOffsetYMin); g2.draw(line); line = new Line2D.Double(loc.getMaxX() - indicatorOffsetXMin, loc.getMaxY() - indicatorOffsetYMin, loc.getMaxX() - indicatorOffsetXMin, loc.getMaxY() - indicatorOffsetYMax); g2.draw(line); } // bottom left if (resizeDirection == ResizeDirection.BOTTOM_LEFT || resized != null && resized.getDirection() == ResizeDirection.BOTTOM_LEFT) { g2.setColor(Color.BLACK); line = new Line2D.Double(loc.getX() + offset, loc.getMaxY() - (startValue + distance * 2), loc.getX() + startValue + distance * 2, loc.getMaxY() - offset); g2.draw(line); line = new Line2D.Double(loc.getX() + offset, loc.getMaxY() - (startValue + distance), loc.getX() + startValue + distance, loc.getMaxY() - offset); g2.draw(line); line = new Line2D.Double(loc.getX() + offset, loc.getMaxY() - startValue, loc.getX() + startValue, loc.getMaxY() - offset); g2.draw(line); } else if (resized == null) { g2.setColor(Color.GRAY); line = new Line2D.Double(loc.getX() + indicatorOffsetXMax - 1, loc.getMaxY() - indicatorOffsetYMin, loc.getX() + indicatorOffsetXMin - 1, loc.getMaxY() - indicatorOffsetYMin); g2.draw(line); line = new Line2D.Double(loc.getX() + indicatorOffsetXMin - 1, loc.getMaxY() - indicatorOffsetYMin, loc.getX() + indicatorOffsetXMin - 1, loc.getMaxY() - indicatorOffsetYMax); g2.draw(line); } // top left if (resizeDirection == ResizeDirection.TOP_LEFT || resized != null && resized.getDirection() == ResizeDirection.TOP_LEFT) { g2.setColor(Color.BLACK); line = new Line2D.Double(loc.getX() + offset, loc.getY() + startValue + distance * 2, loc.getX() + startValue + distance * 2, loc.getY() + offset); g2.draw(line); line = new Line2D.Double(loc.getX() + offset, loc.getY() + startValue + distance, loc.getX() + startValue + distance, loc.getY() + offset); g2.draw(line); line = new Line2D.Double(loc.getX() + offset, loc.getY() + startValue, loc.getX() + startValue, loc.getY() + offset); g2.draw(line); } else if (resized == null) { g2.setColor(Color.GRAY); line = new Line2D.Double(loc.getX() + indicatorOffsetXMax - 1, loc.getY() + indicatorOffsetYMin - 1, loc.getX() + indicatorOffsetXMin - 1, loc.getY() + indicatorOffsetYMin - 1); g2.draw(line); line = new Line2D.Double(loc.getX() + indicatorOffsetXMin - 1, loc.getY() + indicatorOffsetYMin - 1, loc.getX() + indicatorOffsetXMin - 1, loc.getY() + indicatorOffsetYMax - 1); g2.draw(line); } g2.dispose(); } /** * Draws the drag indicators for annotations. * * @param g * the graphics context to draw upon * @param anno * the current annotation to draw * @param loc * the location of the annotation * @param printing * if we are currently printing */ private void drawAnnoDragIndicators(final Graphics2D g, final WorkflowAnnotation anno, final Rectangle2D loc, final boolean printing) { if (printing) { // never draw them for printing return; } AnnotationDragHelper dragged = model.getDragged(); if (dragged.getHoveredOperator() == null) { return; } Graphics2D g2 = (Graphics2D) g.create(); int padding = 15; Rectangle2D opRect = rendererModel.getOperatorRect(dragged.getHoveredOperator()); opRect = new Rectangle2D.Double(opRect.getX(), opRect.getY(), opRect.getWidth(), opRect.getHeight()); Rectangle2D shadowRect = new Rectangle2D.Double(opRect.getX() - padding - 1, opRect.getY() - padding - 1, opRect.getWidth() + 2 * padding + 1, opRect.getHeight() + 2 * padding + 1); g2.setColor(DRAG_LINK_COLOR); g2.setStroke(DRAG_BORDER_STROKE); g2.draw(shadowRect); g2.dispose(); } /** * Shadow non valid drop targets while dragging annotations around. * * @param g * the graphics context to draw upon * @param anno * the current annotation to draw * @param printing * if we are currently printing */ private void shadowOperatorsWhileDragging(final Graphics2D g, final WorkflowAnnotation anno, final boolean printing) { if (printing) { // never draw them for printing return; } AnnotationDragHelper dragged = model.getDragged(); // only shadow if we are actually dragging and the operator annotation is unsnapped if (!dragged.isUnsnapped() || !dragged.isDragInProgress()) { return; } Graphics2D g2 = (Graphics2D) g.create(); // shadow operators which are not a valid drop target for (Operator op : anno.getProcess().getOperators()) { if (anno instanceof OperatorAnnotation) { if (op.equals(((OperatorAnnotation) anno).getAttachedTo())) { continue; } } WorkflowAnnotations annotations = rendererModel.getOperatorAnnotations(op); if (annotations != null && !annotations.isEmpty()) { overshadowRect(rendererModel.getOperatorRect(op), g2); } } g2.dispose(); } /** * Shadows the given rectangle. Gives a disabled look to the given area. * * @param rect * the area to draw the shadow over * @param g * the context to draw upon */ private void overshadowRect(final Rectangle2D rect, final Graphics2D g) { Graphics2D g2 = (Graphics2D) g.create(); g2.setColor(GRAY_OUT); g2.fill(rect); g2.dispose(); } /** * Draws indicator in case the annotation text overflows on the y axis. * * @param anno * the annotation * @param g * the graphics context to draw upon * @param loc * the location of the annotation * @param printing * if we are currently printing */ private void drawOverflowIndicator(final WorkflowAnnotation anno, final Graphics2D g, final Rectangle2D loc, final boolean printing) { if (printing) { // never draw them for printing return; } Graphics2D g2 = (Graphics2D) g.create(); int size = 20; int xOffset = 10; int yOffset = 10; int stepSize = size / 4; int dotSize = 3; int x = (int) loc.getMaxX() - size - xOffset; int y = (int) loc.getMaxY() - size - yOffset; GradientPaint gp = new GradientPaint(x, y, Color.WHITE, x, y + size * 1.5f, Color.LIGHT_GRAY); g2.setPaint(gp); g2.fillRect(x, y, size, size); g2.setColor(Color.BLACK); g2.drawRect(x, y, size, size); g2.fillOval(x + stepSize, y + stepSize * 2, dotSize, dotSize); g2.fillOval(x + stepSize * 2, y + stepSize * 2, dotSize, dotSize); g2.fillOval(x + stepSize * 3, y + stepSize * 2, dotSize, dotSize); g2.dispose(); } /** * Creates an image of the given annotation and caches it with the specified cache id. * * @param anno * the annotation to cache * @param cacheId * the cache id for the given annotation * @return the cached image */ private Image cacheAnnotationImage(final WorkflowAnnotation anno, final int cacheId) { Rectangle2D loc = anno.getLocation(); // paint each annotation with the same JEditorPane Dimension size = new Dimension((int) loc.getWidth(), (int) loc.getHeight()); pane.setSize(size); pane.setText(AnnotationDrawUtils.createStyledCommentString(anno)); pane.setCaretPosition(0); // draw annotation area to image and then to graphics // otherwise heavyweight JEdiorPane draws over everything and outside of panel BufferedImage img = new BufferedImage((int) loc.getWidth(), (int) loc.getHeight(), BufferedImage.TYPE_INT_ARGB); Graphics2D gImg = img.createGraphics(); gImg.setRenderingHints(ProcessDrawer.HI_QUALITY_HINTS); // without this, the text is pixelated on half opaque backgrounds gImg.setComposite(AlphaComposite.SrcOver); // paint JEditorPane to image pane.paint(gImg); displayCache.put(anno.getId(), new WeakReference<Image>(img)); cachedID.put(anno.getId(), cacheId); return img; } /** * Bypass the cache and the speedy image drawing and directly paint the JEditorPane to the * context. Required for printing in SVG format which would turn out pixelated if it were drawn * as an image. * * @param anno * the annotation to draw * @param g2 * the graphics context to draw upon */ private void printAnnotationFromEditor(final WorkflowAnnotation anno, final Graphics2D g2) { Graphics2D gPr = (Graphics2D) g2.create(); Rectangle2D loc = anno.getLocation(); gPr.translate(loc.getX(), loc.getY()); gPr.setClip(0, 0, (int) loc.getWidth(), (int) loc.getHeight()); // paint each annotation with the same JEditorPane Dimension size = new Dimension((int) loc.getWidth(), (int) loc.getHeight()); pane.setSize(size); pane.setText(AnnotationDrawUtils.createStyledCommentString(anno)); pane.setCaretPosition(0); // draw annotation area to image and then to graphics // otherwise heavyweight JEdiorPane draws over everything and outside of panel // paint JEditorPane to context pane.paint(gPr); gPr.dispose(); } /** * Creates a unique id for a {@link WorkflowAnnotation}, but does <strong>not</strong> take x/y * coordinates into account. Reason is that a cached image can still be used if the x/y * coordinates have changed. * * @param anno * the annotation for which to calculate the cache id * @return a unique id identifying an annotation */ private Integer createCacheId(final WorkflowAnnotation anno) { final int prime = 31; int result = 1; result = prime * result + (anno.getComment() == null ? 0 : anno.getComment().hashCode()); result = prime * result + (anno.getLocation() == null ? 0 : new Double(anno.getLocation().getWidth()).hashCode()); result = prime * result + (anno.getLocation() == null ? 0 : new Double(anno.getLocation().getHeight()).hashCode()); result = prime * result + (anno.getStyle() == null ? 0 : anno.getStyle().hashCode()); result = prime * result + (anno.wasResized() ? 1231 : 1237); return result; } /** * Checks whether some process interaction (hovering over operators/ports/dragging/connecting * ports) is going on. * * @param rendererModel * the process renderer model instance * @return {@code true} if some process interaction is happening; {@code false} otherwise */ public static boolean isProcessInteractionHappening(final ProcessRendererModel rendererModel) { if (rendererModel == null) { throw new IllegalArgumentException("rendererModel must not be null!"); } return !(rendererModel.getHoveringOperator() == null && rendererModel.getHoveringPort() == null && rendererModel.getHoveringConnectionSource() == null && !rendererModel.isDragStarted() && rendererModel.getConnectingPortSource() == null); } }