/* Alloy Analyzer 4 -- Copyright (c) 2006-2009, Felix Chang * * Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files * (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, * merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is * furnished to do so, subject to the following conditions: * * The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. * * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES * OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE * LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF * OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. */ package edu.mit.csail.sdg.alloy4graph; import java.awt.Color; import java.awt.geom.CubicCurve2D; import java.awt.geom.GeneralPath; import java.awt.geom.Rectangle2D; import static java.lang.StrictMath.PI; import static java.lang.StrictMath.sin; import static java.lang.StrictMath.cos; import static java.lang.StrictMath.atan2; import static java.lang.StrictMath.toRadians; import static edu.mit.csail.sdg.alloy4graph.Artist.getBounds; import static edu.mit.csail.sdg.alloy4graph.Graph.selfLoopA; import static edu.mit.csail.sdg.alloy4graph.Graph.selfLoopGL; import static edu.mit.csail.sdg.alloy4graph.Graph.selfLoopGR; import static edu.mit.csail.sdg.alloy4graph.Graph.esc; /** Mutable; represents a graphical edge. * * <p><b>Thread Safety:</b> Can be called only by the AWT event thread. */ public final strictfp class GraphEdge { // =============================== adjustable options =========================================================================== /** The angle (in radian) to fan out the arrow head, if the line is not bold. */ private final double smallFan = toRadians(16); /** The angle (in radian) to fan out the arrow head, if the line is bold. */ private final double bigFan = toRadians(32); // =============================== cached for performance efficiency ============================================================ /** The maximum ascent and descent. We deliberately do NOT make this field "static" because only AWT thread can call Artist. */ private final int ad = Artist.getMaxAscentAndDescent(); // =============================== fields ======================================================================================= /** a user-provided annotation that will be associated with this edge (can be null) (need not be unique) */ public final Object uuid; /** a user-provided annotation that will be associated with this edge (all edges with same group will be highlighted together) */ public final Object group; /** The "from" node; must stay in sync with GraphNode.ins and GraphNode.outs and GraphNode.selfs */ private GraphNode a; /** The "to" node; must stay in sync with GraphNode.ins and GraphNode.outs and GraphNode.selfs */ private GraphNode b; /** The label (can be ""); NOTE: label will be drawn only if the start node is not a dummy node. */ private final String label; /** Whether to draw an arrow head on the "from" node; default is false. */ private boolean ahead = false; /** Whether to draw an arrow head on the "to" node; default is true. */ private boolean bhead = true; /** The color of the edge; default is BLACK; never null. */ private Color color = Color.BLACK; /** The line-style of the edge; default is SOLID; never null. */ private DotStyle style = DotStyle.SOLID; /** The edge weight; default is 1; always between 1 and 10000 inclusively. */ private int weight = 1; /** The location and size of the label box; initially (0, 0, label.width, label.height) */ private final AvailableSpace.Box labelbox; /** The actual path corresponding to this edge; initially null until it's computed. */ private Curve path = null; // =========================================================================s==================================================== /** Construct an edge from "from" to "to" with the given arrow head settings, then add the edge to the graph. */ GraphEdge(GraphNode from, GraphNode to, Object uuid, String label, boolean drawArrowHeadOnFrom, boolean drawArrowHeadOnTo, DotStyle style, Color color, Object group) { if (group instanceof GraphNode) throw new IllegalArgumentException("group cannot be a GraphNode"); if (group instanceof GraphEdge) throw new IllegalArgumentException("group cannot be a GraphEdge"); if (group == null) { group = new Object(); } a = from; b = to; if (a.graph != b.graph) throw new IllegalArgumentException("You cannot draw an edge between two different graphs."); if (a == b) { a.selfs.add(this); } else { a.outs.add(this); b.ins.add(this); } a.graph.edgelist.add(this); this.uuid = uuid; this.group = group; this.label = (label==null) ? "" : label; this.ahead = drawArrowHeadOnFrom; this.bhead = drawArrowHeadOnTo; if (style!=null) this.style = style; if (color!=null) this.color = color; if (this.label.length()>0) { Rectangle2D box = getBounds(false, label); labelbox = new AvailableSpace.Box(0, 0, (int) box.getWidth(), (int) box.getHeight()); } else { labelbox = new AvailableSpace.Box(0, 0, 0, 0); } } /** Construct an edge from "from" to "to", then add the edge to the graph. */ public GraphEdge(GraphNode from, GraphNode to, Object uuid, String label, Object group) { this(from, to, uuid, label, false, true, null, null, group); } /** Returns the "from" node. */ public GraphNode a() { return a; } /** Returns the "to" node. */ public GraphNode b() { return b; } /** Swaps the "from" node and "to" node. */ void reverse() { if (a == b) return; a.outs.remove(this); b.ins.remove(this); a.ins.add(this); b.outs.add(this); GraphNode x=a; a=b; b=x; } /** Changes the "to" node to the given node. */ void change(GraphNode newTo) { if (b.graph != newTo.graph) throw new IllegalArgumentException("You cannot draw an edge between two different graphs."); if (a==b) a.selfs.remove(this); else { a.outs.remove(this); b.ins.remove(this); } b = newTo; if (a==b) a.selfs.add(this); else { a.outs.add(this); b.ins.add(this); } } /** Return the X coordinate of the top-left corner of the label box. */ public int getLabelX() { return labelbox.x; } /** Return the Y coordinate of the top-left corner of the label box. */ public int getLabelY() { return labelbox.y; } /** Return the width of the label box. */ public int getLabelW() { return labelbox.w; } /** Return the height of the label box. */ public int getLabelH() { return labelbox.h; } /** Returns the edge weight (which is always between 1 and 10000 inclusively). */ public int weight() { return weight; } /** Returns the line style; never null. */ public DotStyle style() { return style; } /** Returns the line color; never null. */ public Color color() { return color; } /** Returns true if we will draw an arrow head on the "from" node. */ public boolean ahead() { return ahead; } /** Returns true if we will draw an arrow head on the "to" node. */ public boolean bhead() { return bhead; } /** Returns the label on this edge. */ public String label() { return label; } /** Sets the edge weight between 1 and 10000. */ public GraphEdge set(int weightBetween1And10000) { if (weightBetween1And10000 < 1) weightBetween1And10000 = 1; if (weightBetween1And10000 > 10000) weightBetween1And10000 = 10000; weight = weightBetween1And10000; return this; } /** Sets whether we will draw an arrow head on the "from" node, and whether we will draw an arrow head on the "to" node. */ public GraphEdge set(boolean from, boolean to) { this.ahead = from; this.bhead = to; return this; } /** Sets the line style. */ public GraphEdge set(DotStyle style) { if (style != null) this.style = style; return this; } /** Sets the line color. */ public GraphEdge set(Color color) { if (color != null) this.color = color; return this; } /** Returns the current path; if the path was not yet assigned, it returns a straight line from "from" node to "to" node. */ Curve path() { if (path==null) resetPath(); return path; } /** Reset the path as a straightline from the center of the "from" node to the center of the "to" node. */ void resetPath() { double ax = a.x(), ay = a.y(); if (a==b) { double w = 0; for(int n = a.selfs.size(), i = 0; i < n; i++) { if (i==0) w = a.getWidth()/2 + selfLoopA; else w = w + getBounds(false, a.selfs.get(i-1).label()).getWidth() + selfLoopGL + selfLoopGR; GraphEdge e = a.selfs.get(i); if (e!=this) continue; double h=a.getHeight()/2D*0.7D, k=0.55238D, wa=(a.getWidth()/2.0D), wb=w-wa; e.path = new Curve(ax, ay); e.path.cubicTo(ax, ay-k*h, ax+wa-k*wa, ay-h, ax+wa, ay-h); e.path.cubicTo(ax+wa+k*wb, ay-h, ax+wa+wb, ay-k*h, ax+wa+wb, ay); e.path.cubicTo(ax+wa+wb, ay+k*h, ax+wa+k*wb, ay+h, ax+wa, ay+h); e.path.cubicTo(ax+wa-k*wa, ay+h, ax, ay+k*h, ax, ay); e.labelbox.x = (int) (ax + w + selfLoopGL); e.labelbox.y = (int) (ay - getBounds(false, e.label()).getHeight()/2); break; } } else { int i=0, n=0; for(GraphEdge e: a.outs) { if (e==this) i=n++; else if (e.b==b) n++; } double cx=b.x(), cy=b.y(), bx=(ax+cx)/2, by=(ay+cy)/2; path = new Curve(ax, ay); if (n>1 && (n&1)==1) { if (i<n/2) bx = bx - (n/2-i)*10; else if (i>n/2) bx = bx + (i-n/2)*10; path.lineTo(bx, by).lineTo(cx, cy); } else if (n>1) { if (i<n/2) bx = bx - (n/2-i)*10 + 5; else bx = bx + (i-n/2)*10 + 5; path.lineTo(bx, by).lineTo(cx, cy); } else { path.lineTo(cx, cy); } } } /** Given that this edge is already well-laid-out, this method moves the label hoping to avoid/minimize overlap. */ void repositionLabel(AvailableSpace sp) { if (label.length()==0 || a==b) return; // labels on self-edges are already re-positioned by GraphEdge.resetPath() final int gap = style==DotStyle.BOLD ? 4 : 2; // If the line is bold, we need to shift the label to the right a little bit boolean failed = false; Curve p = path; for(GraphNode a = this.a; a.shape()==null;) { GraphEdge e = a.ins.get(0); a = e.a; p = e.path().join(p); } for(GraphNode b = this.b; b.shape()==null;) { GraphEdge e = b.outs.get(0); b = e.b; p = p.join(e.path()); } for(double t=0.5D; ; t=t+0.05D) { if (t>=1D) { failed=true; t=0.7D; } double x1 = p.getX(t), y = p.getY(t), x2 = p.getXatY(y+labelbox.h, t, 1D, x1); int x = (int) (x1<x2 ? x2+gap : x1+gap); if (failed || sp.ok(x, (int)y, labelbox.w, labelbox.h)) { sp.add(labelbox.x=x, labelbox.y=(int)y, labelbox.w, labelbox.h); return; } double t2=1D-t; x1 = p.getX(t2); y = p.getY(t2); x2 = p.getXatY(y+labelbox.h, t2, 1D, x1); x = (int) (x1<x2 ? x2+gap : x1+gap); if (sp.ok(x, (int)y, labelbox.w, labelbox.h)) { sp.add(labelbox.x=x, labelbox.y=(int)y, labelbox.w, labelbox.h); return; } } } /** Positions the arrow heads of the given edge properly. */ void layout_arrowHead() { Curve c=path(); if (ahead() && a.shape()!=null) { double in=0D, out=1D; while(StrictMath.abs(out-in)>0.0001D) { double t=(in+out)/2; if (a.contains(c.getX(t), c.getY(t))) in=t; else out=t; } c.chopStart(in); } if (bhead() && b.shape()!=null) { double in=1D, out=(a==b ? 0.5D : 0D); while(StrictMath.abs(out-in)>0.0001D) { double t=(in+out)/2; if (b.contains(c.getX(t), c.getY(t))) in=t; else out=t; } c.chopEnd(in); } } /** Assuming this edge's coordinates have been properly assigned, and given the current zoom scale, draw the edge. */ void draw(Artist gr, double scale, GraphEdge highEdge, Object highGroup) { final int top = a.graph.getTop(), left = a.graph.getLeft(); gr.translate(-left, -top); if (highEdge==this) { gr.setColor(color); gr.set(DotStyle.BOLD, scale); } else if ((highEdge==null && highGroup==null) || highGroup==group) { gr.setColor(color); gr.set(style, scale); } else { gr.setColor(Color.LIGHT_GRAY); gr.set(style, scale); } if (a == b) { gr.draw(path); } else { // Concatenate this path and its connected segments into a single VizPath object, then draw it Curve p=null; GraphEdge e=this; while(e.a.shape() == null) e = e.a.ins.get(0); // Let e be the first segment of this chain of connected segments while(true) { p = (p==null) ? e.path : p.join(e.path); if (e.b.shape()!=null) break; e = e.b.outs.get(0); } gr.drawSmoothly(p); } gr.set(DotStyle.SOLID, scale); gr.translate(left, top); if (highEdge==null && highGroup==null && label.length()>0) drawLabel(gr, color, null); drawArrowhead(gr, scale, highEdge, highGroup); } /** Assuming this edge's coordinates have been properly assigned, and given the desired color, draw the edge label. */ void drawLabel(Artist gr, Color color, Color erase) { if (label.length()>0) { final int top = a.graph.getTop(), left = a.graph.getLeft(); gr.translate(-left, -top); if (erase!=null && a!=b) { Rectangle2D.Double rect = new Rectangle2D.Double(labelbox.x, labelbox.y, labelbox.w, labelbox.h); gr.setColor(erase); gr.draw(rect, true); } gr.setColor(color); gr.drawString(label, labelbox.x, labelbox.y+Artist.getMaxAscent()); gr.translate(left, top); return; } } /** Assuming this edge's coordinates have been assigned, and given the current zoom scale, draw the arrow heads. */ private void drawArrowhead(Artist gr, double scale, GraphEdge highEdge, Object highGroup) { final double tipLength = ad * 0.6D; final int top = a.graph.getTop(), left = a.graph.getLeft(); // Check to see if this edge is highlighted or not double fan = (style==DotStyle.BOLD ? bigFan : smallFan); if (highEdge==this) { fan=bigFan; gr.setColor(color); gr.set(DotStyle.BOLD, scale); } else if ((highEdge==null && highGroup==null) || highGroup==group) { gr.setColor(color); gr.set(style, scale); } else { gr.setColor(Color.LIGHT_GRAY); gr.set(style, scale); } for(GraphEdge e = this ; ; e = e.b.outs.get(0)) { if ((e.ahead && e.a.shape()!=null) || (e.bhead && e.b.shape()!=null)) { Curve cv=e.path(); if (e.ahead && e.a.shape()!=null) { CubicCurve2D.Double bez = cv.list.get(0); double ax = bez.x1, ay = bez.y1, bx = bez.ctrlx1, by = bez.ctrly1; double t = PI + atan2(ay-by, ax-bx); double gx1 = ax + tipLength*cos(t-fan), gy1 = ay + tipLength*sin(t-fan); double gx2 = ax + tipLength*cos(t+fan), gy2 = ay + tipLength*sin(t+fan); GeneralPath gp=new GeneralPath(); gp.moveTo((float)(gx1-left), (float)(gy1-top)); gp.lineTo((float)(ax-left), (float)(ay-top)); gp.lineTo((float)(gx2-left), (float)(gy2-top)); gp.closePath(); gr.draw(gp,true); } if (e.bhead && e.b.shape()!=null) { CubicCurve2D.Double bez = cv.list.get(cv.list.size()-1); double bx = bez.x2, by = bez.y2, ax = bez.ctrlx2, ay = bez.ctrly2; double t = PI + atan2(by-ay, bx-ax); double gx1 = bx + tipLength*cos(t-fan), gy1 = by + tipLength*sin(t-fan); double gx2 = bx + tipLength*cos(t+fan), gy2 = by + tipLength*sin(t+fan); GeneralPath gp=new GeneralPath(); gp.moveTo((float)(gx1-left), (float)(gy1-top)); gp.lineTo((float)(bx-left), (float)(by-top)); gp.lineTo((float)(gx2-left), (float)(gy2-top)); gp.closePath(); gr.draw(gp,true); } } if (e.b.shape()!=null) break; } } /** Returns a DOT representation of this edge (or "" if the start node is a dummy node) */ @Override public String toString() { GraphNode a = this.a, b = this.b; if (a.shape() == null) return ""; // This means this edge is virtual while(b.shape() == null) { b = b.outs.get(0).b; } String color = Integer.toHexString(this.color.getRGB() & 0xFFFFFF); while(color.length() < 6) { color = "0" + color; } StringBuilder out = new StringBuilder(); out.append("\"N" + a.pos() + "\""); out.append(" -> "); out.append("\"N" + b.pos() + "\""); out.append(" ["); out.append("uuid = \"" + (uuid==null ? "" : esc(uuid.toString())) + "\""); out.append(", color = \"#" + color + "\""); out.append(", fontcolor = \"#" + color + "\""); out.append(", style = \"" + style.getDotText() + "\""); out.append(", label = \"" + esc(label) + "\""); out.append(", dir = \"" + (ahead && bhead ? "both" : (bhead ? "forward" : "back")) + "\""); out.append(", weight = \"" + weight + "\""); out.append("]\n"); return out.toString(); } }