/* * Copyright 2003-2010 Tufts University Licensed under the * Educational Community License, Version 2.0 (the "License"); you may * not use this file except in compliance with the License. You may * obtain a copy of the License at * * http://www.osedu.org/licenses/ECL-2.0 * * Unless required by applicable law or agreed to in writing, * software distributed under the License is distributed on an "AS IS" * BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express * or implied. See the License for the specific language governing * permissions and limitations under the License. */ package tufts.vue; import tufts.Util; import static tufts.Util.fmt; import static tufts.Util.grow; import java.util.*; import java.awt.Font; import java.awt.Color; import java.awt.Shape; import java.awt.Graphics2D; import java.awt.BasicStroke; import java.awt.Composite; import java.awt.AlphaComposite; import java.awt.font.*; import java.awt.geom.*; import javax.swing.JTextArea; /** * Draws a view of a Link on a java.awt.Graphics2D context, * and offers code for user interaction. * * Note that links have position (always their mid-point) only so that * there's a place to connect for another link and/or a place for * the label. Having a size doesn't actually make much sense, tho * we inherit from LWComponent. * * @author Scott Fraize * @version $Revision: 1.243 $ / $Date: 2010-02-03 19:17:41 $ / $Author: mike $ */ public class LWLink extends LWComponent implements LWSelection.ControlListener, Runnable { protected static final org.apache.log4j.Logger Log = org.apache.log4j.Logger.getLogger(LWLink.class); private static boolean PruneControlsEnabled = false; private static boolean DisplayLabels = true; // Ideally, we want this to be false: it's a more accurate representation of // what's displayed: the control points only show up when selected. private static final boolean IncludeControlPointsInBounds = false; public final static Font DEFAULT_FONT = VueResources.getFont("link.font"); public final static Color DEFAULT_LABEL_COLOR = java.awt.Color.darkGray; /** neither endpoint has arrow */ public static final int ARROW_NONE = 0; /** head has an arrow */ public static final int ARROW_HEAD = 0x1; /** tail has an arrow */ public static final int ARROW_TAIL = 0x2; /** both endpoints have arrows */ public static final int ARROW_BOTH = ARROW_HEAD + ARROW_TAIL; /** @deprecated -- use ARROW_HEAD */ public static final int ARROW_EP1 = ARROW_HEAD; /** @deprecated -- use ARROW_TAIL */ public static final int ARROW_EP2 = ARROW_TAIL; // todo: create set of arrow types private final static float ArrowBase = 5; private final static RectangularShape HeadShape = new tufts.vue.shape.Triangle2D(0,0, ArrowBase,ArrowBase*1.3); private final static RectangularShape TailShape = new tufts.vue.shape.Triangle2D(0,0, ArrowBase,ArrowBase*1.3); private static final double ICON_BLOCK_LOD_ZOOM = 0.5; /** * Holds data and defines basic functionality for each endpoint. Currently, we * always have exactly two endpoints, each of which may or may not be connected to * another node. * * If we ever support more than one endpoint on a link (e.g., fan-out links), this * will give us a good start. * * The x/y in the super-class Point2D.Float is the x/y of the actual connection point * at the endpoint node (or link), or the disconnected location if not connected. * Subclassing Point2D.Float make it convenient to do a transformation on the point * if need be. */ // TODO: the endpoint should contain bit for the presence of an arrow head / conncetor specifier private static final class End extends Point2D.Float { LWComponent node; // if null, not connected boolean pruned; double rotation; // normalizing rotation // maybe keep the parent of the endpoint node? //float lineX, lineY; // end of curve / line -- can be different than x / y if there is a connector shape //RectangularShape shape; // e.g. an arrow -- null means none final Point2D.Float mapPoint = new Point2D.Float(); // for control points float getX(LWContainer focal) { return node == null ? x : (float) node.getAncestorX(focal); } float getY(LWContainer focal) { return node == null ? y : (float) node.getAncestorY(focal); } boolean hasPrunedNode() { return node != null && node.isHidden(HideCause.PRUNE); } boolean isPruning() { return PruneControlsEnabled && pruned; //return PruneControlsEnabled && node != null && node.isPruned(); } boolean isConnected() { return node != null; } boolean hasNode() { return node != null; } Point2D.Float getPoint() { return this; } Point2D.Float getMapPoint() { return mapPoint; } void duplicate(End end) { x = end.x; y = end.y; mapPoint.x = end.mapPoint.x; mapPoint.y = end.mapPoint.y; } void setPoint(final LWLink link, final float newX, final float newY, final Key key) { if (isConnected()) { //VUE.Log.debug(this + "; setting pixel tail point for connected link: " + x + "," + y); final UndoManager um = link.getUndoManager(); if (um != null && !um.isUndoing()) { if (DEBUG.Enabled) Util.printClassTrace("tufts.vue", this + "; setting pixel point for connected link: " + newX + "," + newY + "; " + key); } else ; // this is needed during undo } final Object old = new Point2D.Float(this.x, this.y); this.x = newX; this.y = newY; link.mRecompute = true; link.notify(key, old); } // //----------------------------------------------------------------------------- // // Prune control support // //----------------------------------------------------------------------------- // float pruneCtrlOffset; // private class PruneCtrl extends LWSelection.Controller { // final AffineTransform tx = new AffineTransform(); // double ctrlRotation; // void update(double onScreenScale) { // super.x = super.y = 0; // tx.setToTranslation(mapPoint.x, mapPoint.y); // tx.rotate(rotation); // tx.translate(0, pruneCtrlOffset / onScreenScale); // tx.transform(this,this); // setColor(pruned ? Color.red : Color.lightGray); // // rotate to square parallel on line, plus 45 degrees // // to get diamond display // ctrlRotation = rotation + Math.PI / 4; // } // public final RectangularShape getShape() { return PruneCtrlShape; } // public final double getRotation() { return ctrlRotation; } // } // // todo opt: could lazy create these... // final PruneCtrl pruneControl = new PruneCtrl(); public String toString() { String s = "End["; if (pruned) s += "PRUNED "; else s += "-open- "; s += String.format("%4.0f,%4.0f", x, y); //String s = "End[" + String.format("%4.0f,%4.0f", x, y); if (node != null) s += " " + node; return s + "]"; } }; private final End head = new End(); private final End tail = new End(); /** center of our bounding box */ private float mCenterX; /** center of our bounding box */ private float mCenterY; /** used when link is straight */ private Line2D.Float mLine = new Line2D.Float(); /** used when link is a quadradic curve (1 control point) */ private QuadCurve2D.Float mQuad = null; /** used when link is a cubic curve (2 control points) */ private CubicCurve2D.Float mCubic = null; /** convenience alias for current curve */ private Shape mCurve = null; /** curve center X -- the curve mid-point */ private float mCurveCenterX; /** curve center Y -- the curve mid-point */ private float mCurveCenterY; /** x/y point pairs on a flattened-into-segments version of the curve for hit detection */ private float[] mPoints; /** current last real point in mPoints, which may contain unused values at the end */ private int mLastPoint; /** the current total length of the line / curve (estimated by segments for curves) */ private float mLength; /** number of curve control points in use: 0=straight, 1=quad curved, 2=cubic curved */ private int mCurveControls = 0; //private boolean ordered = false; // not doing anything with this yet /** has an endpoint moved since we last computed shape? */ private transient boolean mRecompute; private transient LWIcon.Block mIconBlock = new LWIcon.Block(this, 11, 9, Color.darkGray, LWIcon.Block.HORIZONTAL); /** * Used only for restore -- must be public */ public LWLink() { initLink(); } /** * Create a new link between two LWC's */ public LWLink(LWComponent head, LWComponent tail) { initLink(); //if (ep1 == null || ep2 == null) throw new IllegalArgumentException("LWLink: ep1=" + ep1 + " ep2=" + ep2); SetDefaults(this); setHead(head); setTail(tail); computeLink(); } public static void setPruningEnabled(boolean enabled) { PruneControlsEnabled = enabled; } public static boolean isPruningEnabled() { return PruneControlsEnabled; } boolean isCurrentlyPruned() { return PruneControlsEnabled && (head.pruned || tail.pruned); } /** persist with a true value only if the head was user-pruned */ // todo: head/tail user pruning is another bit that would be cleaner // to have persisted inside the End object, which would allow us // to have multiple end points. public Boolean getHeadUserPruned() { return head.pruned ? Boolean.TRUE : null; } public void setHeadUserPruned(Boolean p) { if (head.pruned == p) return; if (alive() && !mXMLRestoreUnderway) { pruneToggle(!head.pruned, getEndpointChain(tail.node)); head.pruned = p; notify(KEY_HeadPruned, head.pruned ? Boolean.FALSE : Boolean.TRUE); } else { head.pruned = p; } } /** persist with a true value only if the tail was user-pruned */ public Boolean getTailUserPruned() { return tail.pruned ? Boolean.TRUE : null; } public void setTailUserPruned(Boolean p) { if (tail.pruned == p) return; if (alive() && !mXMLRestoreUnderway) { pruneToggle(!tail.pruned, getEndpointChain(head.node)); tail.pruned = p; notify(KEY_TailPruned, tail.pruned ? Boolean.FALSE : Boolean.TRUE); } else { tail.pruned = p; } } public void setXMLpruned(Boolean b) { // note: should normally only be called if b is true, // as when false it shouldn't be persisted at all setPruned(b.booleanValue()); } public static void setDisplayLabelsEnabled(boolean display) { DisplayLabels = display; } public static boolean isDisplayLabelsEnabled() { return DisplayLabels; } @Override public boolean hasLabel() { return DisplayLabels && super.hasLabel(); } private void initLink() { disableProperty(KEY_FillColor); } @Override protected void setParent(LWContainer newParent) { super.setParent(newParent); mRecompute = true; } static LWLink SetDefaults(LWLink l) { l.setFont(DEFAULT_FONT); l.setTextColor(DEFAULT_LABEL_COLOR); l.setStrokeWidth(1f); //todo config: default link width return l; } // FYI: javac (mac java version "1.5.0_07") complains about an incompatible return // type in getSlot here if we don't compile this file at the same time as // LWCopmonent.java... (this is a javac bug) public static final Key KEY_LinkArrows = new Key<LWLink,Object>("link.arrows", "vue-head;vue-tail") { private boolean vueHeadOnFromCSS = false; private boolean vueHeadOffFromCSS = false; final Property getSlot(LWLink l) { return l.mArrowState; // if getting a type-mismatch on mLine, feed this file to javac with LWComponent.java at the same time } public boolean setValueFromCSS(LWLink c, String cssKey, String cssValue) { if(cssKey.equals("vue-head")) { if(cssValue.equals("on")) { c.setArrowState(ARROW_HEAD); vueHeadOnFromCSS = true; } else if(cssValue.equals("off")) { c.setArrowState(ARROW_NONE); vueHeadOffFromCSS = true; } } if(cssKey.equals("vue-tail")) { if(cssValue.equals("on")) { if(!vueHeadOnFromCSS || vueHeadOffFromCSS) { c.setArrowState(ARROW_TAIL); } else { c.setArrowState(ARROW_BOTH); } } else if(cssValue.equals("off") && (!vueHeadOnFromCSS || vueHeadOffFromCSS)) { c.setArrowState(ARROW_NONE); } vueHeadOffFromCSS = false; vueHeadOnFromCSS = false; } return true; } }; /*public static final Key KEY_LinkArrows_Head = new Key<LWLink,Object>("link.arrows", "vue-head") { @Override final Property getSlot(LWLink l) { return l.mArrowState; // if getting a type-mismatch on mLine, feed this file to javac with LWComponent.java at the same time } @Override public boolean setValueFromCSS(LWLink c, String cssKey, String cssValue) { System.out.println("vue-head"); if(getValue(c).equals("on")) { c.setArrowState(ARROW_HEAD); } return true; } }; */ private final IntProperty mArrowState = new IntProperty(KEY_LinkArrows, ARROW_TAIL) { void onChange() { mRecompute = true; layout(); } }; public static final Key KEY_LinkShape = new Key<LWLink,Integer>("link.shape") { // do we want this to be a KeyType.STYLE? could argue either way... @Override public void setValue(LWLink link, Integer linkStyle) { link.setControlCount(linkStyle); } @Override public Integer getValue(LWLink link) { return link.getControlCount(); } }; /* public enum LinkStyle { STRAIGHT, QUAD_CURVED, CUBIC_CURVED; } public static final Key KEY_LinkShape = new Key<LWLink,LinkStyle>("link.shape") { // do we want this to be a KeyType.STYLE? could argue either way... @Override public void setValue(LWLink link, LinkStyle linkStyle) { link.setControlCount(linkStyle.ordinal()); } @Override public LinkStyle getValue(LWLink link) { int cc = link.getControlCount(); if (cc == 0) return LinkStyle.STRAIGHT; else if (cc == 1) return LinkStyle.QUAD_CURVED; else return LinkStyle.CUBIC_CURVED; } }; */ public static final Key KEY_LinkHeadPoint = new Key<LWLink,Point2D>("link.head.location") { @Override public void setValue(LWLink l, Point2D val) { l.setHeadPoint(val); } @Override public Point2D getValue(LWLink l) { return l.getHeadPoint(); } }; public static final Key KEY_LinkTailPoint = new Key<LWLink,Point2D>("link.tail.location") { @Override public void setValue(LWLink l, Point2D val) { l.setTailPoint(val); } @Override public Point2D getValue(LWLink l) { return l.getTailPoint(); } }; // another case where a pre-defined boolean key would be handy, that would // automatically handle producing null for false values on persistance // For stuff like this, it could be introspected and/or determined via annotations, // as this kind of property does not need fast setters / getters. Also, // it would automatically handle event generation / undo. public static final Key KEY_HeadPruned = new Key<LWLink,Boolean>("prune.link.head") { @Override public void setValue(LWLink l, Boolean b) { l.setHeadUserPruned(b); } @Override public Boolean getValue(LWLink l) { return l.head.pruned; } }; public static final Key KEY_TailPruned = new Key<LWLink,Boolean>("prune.link.tail") { @Override public void setValue(LWLink l, Boolean b) { l.setTailUserPruned(b); } @Override public Boolean getValue(LWLink l) { return l.tail.pruned; } }; private final static String Key_Control_0 = "link.control.0"; private final static String Key_Control_1 = "link.control.1"; /** * @param key property key (see LWKey) * @return object representing appropriate value */ @Override public Object getPropertyValue(Object key) { //if (key == LWKey.LinkCurves) return new Integer(getControlCount());else if (key == Key_Control_0) return getCtrlPoint0(); else if (key == Key_Control_1) return getCtrlPoint1(); else return super.getPropertyValue(key); } @Override public void setPropertyImpl(Object key, Object val, Object context) { //if (key == LWKey.LinkCurves) setControlCount(((Integer) val).intValue());else if (key == LWKey.Location) { // This is a bit of a hack, in that we're relying on the fact that the only // thing to call setProperty with a Location key right now is the // UndoManager. In any case, on undo, we do NOT want to guess at // a translation for the link... tho we have no other choice! final Point2D.Float loc = (Point2D.Float) val; undoLocation(loc.x, loc.y); } else if (key == Key_Control_0) setCtrlPoint0((Point2D)val); else if (key == Key_Control_1) setCtrlPoint1((Point2D)val); else super.setPropertyImpl(key, val, context); } @Override public boolean supportsUserLabel() { return !isCurrentlyPruned(); } @Override public boolean supportsReparenting() { return false; } @Override public boolean handleSingleClick(MapMouseEvent e) { // // we don't get this event if there are modifiers down... // Log.debug("handleSingleClick " + this); // if (e.isMetaDown() && hasFlag(Flag.DATA_LINK)) { // if (tufts.vue.ds.Schema.isSameRow(getHead(), getTail())) { // Log.debug("COLLAPSE"); // } // } // returning true will disallow label-edit // when single clicking over an icon. return mIconBlock.contains(e.getMapX(), e.getMapY()); // TODO: need e.getLocalPoint(this) } public boolean handleDoubleClick(MapMouseEvent e) { // we don't get this event if there are modifiers down... //Log.debug("handleDoubleClick " + this); return mIconBlock.handleDoubleClick(e); } // castor-1.1.2.1-xml.jar debugging for validated marshalling, which is suddenly failing for links: // public LWComponent getHead() { // LWComponent c = _getHead(); // out("RETURNING GET-HEAD " + c + " id=" + (c==null?"<none>":c.getID())); // return c; // } // public LWComponent getTail() { // LWComponent c = _getTail(); // out("RETURNING GET-TAIL " + c + " id=" + (c==null?"<none>":c.getID())); // return c; // } /** @return the component connected at the head end, or null if none or if it's pruned */ public LWComponent getHead() { if (head.node == null || head.node.isHidden(HideCause.PRUNE)) return null; else return head.node; } /** @return the component connected at the tail end, or null if none or of it's pruned*/ public LWComponent getTail() { if (tail.node == null || tail.node.isHidden(HideCause.PRUNE)) return null; else return tail.node; } /** persistance only */ public LWComponent getPersistHead() { return head.node; } /** persistance only */ public LWComponent getPersistTail() { return tail.node; } /** persistance only */ public void setPersistHead(LWComponent c) { head.node = c; if (c != null) c.addLinkRef(this); } /** persistance only */ public void setPersistTail(LWComponent c) { tail.node = c; if (c != null) c.addLinkRef(this); } public void setHead(LWComponent c) { if (c == head.node) return; if (head.hasNode()) head.node.removeLinkRef(this); final LWComponent oldHead = head.node; setPersistHead(c); mRecompute = true; addCleanupTask(this); if (alive()) notify("link.head.connect", new Undoable(oldHead) { void undo() { setHead(oldHead); }} ); } public void setTail(LWComponent c) { if (c == tail.node) return; if (tail.hasNode()) tail.node.removeLinkRef(this); final LWComponent oldTail = tail.node; setPersistTail(c); mRecompute = true; addCleanupTask(this); if (alive()) notify("link.tail.connect", new Undoable(oldTail) { void undo() { setTail(oldTail); }} ); } void disconnectFrom(LWComponent c) { if (head.node == c) setHead(null); else if (tail.node == c) setTail(null); else throw new IllegalArgumentException(this + " cannot disconnect: not connected to " + c); } public void setHeadPoint(float x, float y) { head.setPoint(this, x, y, KEY_LinkHeadPoint); } public void setTailPoint(float x, float y) { tail.setPoint(this, x, y, KEY_LinkTailPoint); } // // TODO: setting SKIP_NODE_ENPOINT_PRUNE to true is probably a // // better tradeoff, tho even better would probably be to reverse // // the endpoint effect of the prune controls, where the link // // itself is pruned down to a stub at the node you want to have // // more focus on, including an action to prune ALL outbound links // // on that node, and then users could re-enabled just the stubs // // they're interested in. See VUE-1239 for what prompted this. // private static final boolean SKIP_NODE_ENDPOINT_PRUNE = false; private void debug(String s) { Log.debug(toString() + ": " + s); } /** interface ControlListener handler */ public void controlPointPressed(int index, MapMouseEvent e) { if (DEBUG.LINK||DEBUG.MOUSE) debug("controlPointPressed " + index + "\n\thead: " + head + "\n\ttail: " + tail ); boolean acted = true; // if (PruneControlsEnabled) { // //if (index == CPruneHead && head.hasNode()) { // if (index == CHead && head.hasNode()) { // toggleHeadPrune(); // if (SKIP_NODE_ENDPOINT_PRUNE) // may have no model effect of no outbound links on pruned node // notify(LWKey.Repaint); // //} else if (index == CPruneTail && tail.hasNode()) { // } else if (index == CTail && tail.hasNode()) { // toggleTailPrune(); // if (SKIP_NODE_ENDPOINT_PRUNE) // notify(LWKey.Repaint); // } // } else // acted = false; if (PruneControlsEnabled) { if (index == CHead && head.hasNode()) { if (head.isPruning() || tail.hasNode()) toggleHeadPrune(); } else if (index == CTail && tail.hasNode()) { if (tail.isPruning() || head.hasNode()) toggleTailPrune(); } } else acted = false; if (acted) { // todo: this would make more sense handled in our caller (MapViewer) UndoManager um = getUndoManager(); if (um != null) um.mark(); } } private void toggleHeadPrune() { if (DEBUG.LINK) debug("toggleHeadPrune"); // [FIXED] here's the recursive prune problem: getEndpointChain is proceeding // through other prunes. We can presumably stop that, but I've no idea if // that's going to break all sorts of other stuff. //pruneToggle(!head.pruned, getEndpointChain(tail.node)); setHeadUserPruned(!head.pruned); //head.pruned = !head.pruned; } private void toggleTailPrune() { if (DEBUG.LINK) debug("toggleTailPrune"); //pruneToggle(!tail.pruned, getEndpointChain(head.node)); setTailUserPruned(!tail.pruned); //tail.pruned = !tail.pruned; } void clearUserPrunes() { setHeadUserPruned(false); setTailUserPruned(false); //tail.pruned = head.pruned = false; } boolean isPrunedBelow(LWComponent node) { if (node == head.node) return head.isPruning(); else if (node == tail.node) return tail.isPruning(); else { Log.warn("not an endpoint of " + this + ": " + node); return false; } } private void pruneToggle(final boolean hide, Collection<LWComponent> bag) { for (LWComponent c : bag) { if (c != this) // never hide us (the source of the prune) pruneNode(c, hide); } } private static void pruneNode(final LWComponent c, final boolean prune) { if (DEBUG.LINK) Log.debug("prune to " + prune + ": " + c); c.setPruned(prune); c.setHidden(HideCause.PRUNE, prune); for (LWComponent child : c.getChildren()) pruneNode(child, prune); if (DEBUG.LINK) Log.debug("prunedto " + prune + ": " + c); } private Collection<LWComponent> getEndpointChain(LWComponent endpoint) { if (endpoint == null) return Collections.EMPTY_LIST; final HashSet set = new HashSet(); // pre-add us to the set, so we can't back up through our other endpoint: //set.add(this); final LWComponent exclude = (endpoint == head.node ? tail.node : head.node); if (DEBUG.LINK) { Log.debug("getEndpointChain:" + "\n\tlink(this): " + this + "\n\t endpoint: " + endpoint + "\n\t excluding: " + exclude); } endpoint.getLinkChain(set, exclude); // if (SKIP_NODE_ENDPOINT_PRUNE) // set.remove(endpoint); //set.remove(endpoint == head.node ? tail.node : head.node); if (DEBUG.LINK) { Util.dump(set); Log.debug("DONE\n"); } return set; } /** * @return all linked components: for a link, this is usually just it's endpoints, * but like any other LWComponent, it will also include any other links that connect * directly to us * * The inclusion if it's endpoints is effected by pruning -- pruned endpoints * will not include their nodes. */ @Override public Collection<? extends LWComponent> getConnected() { final List links = getLinks(); final Collection bag = new HashSet(links.size() + 2); // common case size if (head.hasNode() && !head.isPruning()) bag.add(head.node); if (tail.hasNode() && !tail.isPruning()) bag.add(tail.node); if (links.size() > 0) bag.addAll(links); return bag; } /** @return 1.0 -- links never scaled by themselves */ @Override public double getScale() { return 1.0; } @Override protected void setScale(double scale) { ; // do nothing: links don't take on a scale of their own } /** @return same as super class impl, but by default add our own two endpoints */ @Override public Rectangle2D.Float getFanBounds(Rectangle2D.Float r) { final Rectangle2D.Float rect = super.getFanBounds(r); if (head.hasNode()) rect.add(head.node.getBounds()); if (tail.hasNode()) rect.add(tail.node.getBounds()); return rect; } /** @return bounds to use when this is the focal (for links we add our endpoint bounds via getFanBounds) */ @Override public Rectangle2D.Float getFocalBounds() { return getFanBounds(new Rectangle2D.Float()); } // @Override // public boolean isDrawn() { // no longer referenced by requiresPaint, and isHidden not overridable // if (!super.isDrawn()) // return false; // final LWComponent c = parentInParentChildLink(); // if (c != null && c.isCollapsed()) // return false; // else // return true; // } @Override public void draw(DrawContext dc) { if (dc.focal == this) { //------------------------------------------------------- // SPECIAL CASE: draw the link PLUS its individual endpoints. // The focal bounds should have already been getFanBounds(), // so we should be drawing into the appropriate region. //------------------------------------------------------- dc.setClipOptimized(false); transformZero(dc.g); // we'll be in the parent context drawZero(dc); // todo: if endpoint is in another parent, handle the partial transformation // to get there and draw it (or go back up to map and back down) final LWComponent parent = getParent(); final boolean atTopLevel = true; if (getParent() !=null) getParent().isTopLevel(); if (head.hasNode()) { if (head.node.getParent() == getParent() || (atTopLevel && head.node.getParent().isTopLevel())) // no need to transform: already in the parent context or // at the top-level, meaning we're registered in the // same coordinate space (e.g., a layer) head.node.draw(dc.push()); dc.pop(); } if (tail.hasNode()) { if (tail.node.getParent() == getParent() || (atTopLevel && tail.node.getParent().isTopLevel())) // no need to transform: same as above case tail.node.draw(dc.push()); dc.pop(); } } else super.draw(dc); } /** interface ControlListener handler * One of our control points (an endpoint or curve control point). */ public void controlPointMoved(int index, MapMouseEvent e) { setControllerLocation(index, e.getMapX(), e.getMapY(), e); } /** for use by ResizeControl */ void setControllerLocation(int index, Point2D.Float point) { setControllerLocation(index, (float) point.getX(), (float) point.getY(), null); } /** for use by ResizeControl */ void setControllerLocation(int index, float x, float y) { setControllerLocation(index, x, y, null); } /** * dual use: controlPointMoved for ControlListener, and ResizeControl for moving each movable * control in a link separatly. If MapMouseEvent is null, ResizeControl is making use of this. */ private void setControllerLocation(int index, float mapX, float mapY, MapMouseEvent e) { final Point2D.Float local; if (e == null) { local = new Point2D.Float(mapX, mapY); transformMapToZeroPoint(local, local); } else local = e.getLocalPoint(this); //System.out.println("LWLink: control point " + index + " moved"); // TODO: need to getLocalTransform().inverseTransform the x/y back down to local coords. // Would be better if the coords were already translated to local coords? if (index == CHead && !head.isPruning()) { setHead(null); // disconnect from node (already so if e == null) setHeadPoint(local.x, local.y); if (e != null) LinkTool.setMapIndicationIfOverValidTarget(tail.node, this, e); } else if (index == CTail && !tail.isPruning()) { setTail(null); // disconnect from node (already so if e == null) setTailPoint(local.x, local.y); if (e != null) LinkTool.setMapIndicationIfOverValidTarget(head.node, this, e); } else if (index == CCurve1 || index == CCurve2) { // optional control 0 for curve if (mCurveControls == 1) { setCtrlPoint0(local.x, local.y); } else { // TODO: have LWSelection.Controller provide dx/dy, or maybe MapMouseEvent can, // -- these are trailing behind by one repaint! // Or just reflect the line once to double it's length. float dx = mapX - mControlPoints[index].x; float dy = mapY - mControlPoints[index].y; Point2D p = index == CCurve1 ? getCtrlPoint0() : getCtrlPoint1(); if (index == CCurve1) setCtrlPoint0((float) p.getX() + dx, (float) p.getY() + dy); else setCtrlPoint1((float) p.getX() + dx, (float) p.getY() + dy); } } } /** interface ControlListener handler */ public void controlPointDropped(int index, MapMouseEvent e) { LWComponent dropTarget = e.getViewer().getIndication(); // TODO BUG: above doesn't work if everything is selected if (DEBUG.MOUSE) System.out.println("LWLink: control point " + index + " dropped on " + dropTarget); if (dropTarget != null && !e.isShiftDown()) { if (index == CHead && head.node == null && tail.node != dropTarget) setHead(dropTarget); else if (index == CTail && tail.node == null && head.node != dropTarget) setTail(dropTarget); // todo: ensure paint sequence same as LinkTool.makeLink } } // The order of these determine the priority for what can // be selected if the controls overlap. Curve controls // should be sure to have priority over prune controls, // as they can be moved to expose the prune controls if // need be, whereas the prune controls don't move on their own. private static final int CHead = 0; private static final int CTail = 1; private static final int CCurve1 = 2; private static final int CCurve2 = 3; //private static final int CPruneHead = 4; //private static final int CPruneTail = 5; private static final int MAX_CONTROL = 4; private final LWSelection.Controller[] mControlPoints = new LWSelection.Controller[MAX_CONTROL]; private static final RectangularShape ConnectCtrlShape = new Ellipse2D.Float(0,0, 9,9); private static final RectangularShape CurveCtrlShape = new Ellipse2D.Float(0,0, 8,8); private static final RectangularShape PruneCtrlShape = new Rectangle2D.Float(0,0,8,8); private static class ConnectCtrl extends LWSelection.Controller { ConnectCtrl(Point2D.Float mapLoc, End end, boolean farConnect) { super(mapLoc.x, mapLoc.y); if (PruneControlsEnabled && end.isConnected() && farConnect) setColor(end.isPruning() ? Color.red : COLOR_SELECTION); else setColor(end.isConnected() ? null : COLOR_SELECTION_HANDLE); } public final RectangularShape getShape() { return ConnectCtrlShape; } } private static class CurveCtrl extends LWSelection.Controller { CurveCtrl(Point2D p) { super(p); setColor(COLOR_SELECTION_CONTROL); } CurveCtrl(Point2D p, float epx, float epy) { //super(p); super((float) (p.getX() + epx) / 2, (float) (p.getY() + epy) / 2); setColor(COLOR_SELECTION_CONTROL); } public final RectangularShape getShape() { return CurveCtrlShape; } } // private static class PruneCtrl extends LWSelection.Controller { // private final double rotation; // //PruneCtrl(AffineTransform tx, double rot, boolean active) // //PruneCtrl(double rot, boolean active) // PruneCtrl(End end) // { // end.pruneCtrlTx.setToTranslation(end.mapPoint.x, end.mapPoint.y); // end.pruneCtrlTx.rotate(end.rotation); // end.pruneCtrlTx.translate(0, end.pruneCtrlOffset); // end.pruneCtrlTx.transform(this,this); // setColor(end.isPruned ? Color.red : Color.lightGray); // this.rotation = end.rotation + Math.PI / 4; // rotate to square parallel on line, plus 45 degrees to get diamond display // } // public final RectangularShape getShape() { return PruneCtrlShape; } // public final double getRotation() { return rotation; } // } final private static boolean EXCLUDE_CONNECTED = true; final private static boolean INCLUDE_CONNECTED = false; final private static boolean INCLUDE_ENDPOINTS = true; final private static boolean EXCLUDE_ENDPOINTS = false; final private static boolean INCLUDE_PRUNES = true; final private static boolean EXCLUDE_PRUNES = false; /** interface ControlListener */ public LWSelection.Controller[] getControlPoints(double zoom) { return getControls(zoom, isDataLink() ? EXCLUDE_ENDPOINTS : INCLUDE_ENDPOINTS, INCLUDE_CONNECTED, INCLUDE_PRUNES); } /** for ResizeControl -- return only those controls that currently have effect when dragged -- that is, leave out connected points, but include any unconnected, or curve controls */ public LWSelection.Controller[] getControlsWithCurrentDragEffect() { return getControls(1.0, isDataLink() ? EXCLUDE_ENDPOINTS : INCLUDE_ENDPOINTS, EXCLUDE_CONNECTED, EXCLUDE_PRUNES); } private LWSelection.Controller[] getControls (double onScreenScale, boolean endpointDrags, boolean excludeConnected, boolean prunes) { if (mRecompute) computeLink(); // head, tail & curve controls are all in local coordinates // (which for links is local to their parent) -- to produce // map coordinates, we apply the local transform to the // points to get the map location. // TODO OPT: if parent is a map, getZeroTransform is just creating // empty affine transforms, and we're calling transform here // which is going to be a noop. final Point2D.Float mapHead = head.getMapPoint(); final Point2D.Float mapTail = tail.getMapPoint(); final AffineTransform mapTx = getZeroTransform(); mapTx.transform(head.getPoint(), mapHead); mapTx.transform(tail.getPoint(), mapTail); //------------------------------------------------------- // Connection control points //------------------------------------------------------- if (excludeConnected && head.isConnected()) { mControlPoints[CHead] = null; } else if (endpointDrags && !head.hasPrunedNode()) { mControlPoints[CHead] = new ConnectCtrl(mapHead, head, tail.isConnected()); if (DEBUG.BOXES) mControlPoints[CHead].setColor(Color.green); // mark the head } else { mControlPoints[CHead] = null; } if (excludeConnected && tail.isConnected()) mControlPoints[CTail] = null; else if (endpointDrags && !tail.hasPrunedNode()) mControlPoints[CTail] = new ConnectCtrl(mapTail, tail, head.isConnected()); else mControlPoints[CTail] = null; //------------------------------------------------------- // Curve control points //------------------------------------------------------- if (mCurveControls == 1) { mControlPoints[CCurve1] = new CurveCtrl(mapTx.transform(mQuad.getCtrlPt(), null)); mControlPoints[CCurve2] = null; } else if (mCurveControls == 2) { mControlPoints[CCurve1] = new CurveCtrl(mapTx.transform(mCubic.getCtrlP1(), null), mapHead.x, mapHead.y); mControlPoints[CCurve2] = new CurveCtrl(mapTx.transform(mCubic.getCtrlP2(), null), mapTail.x, mapTail.y); } else { mControlPoints[CCurve1] = null; mControlPoints[CCurve2] = null; } // //------------------------------------------------------- // // Pruning control points // //------------------------------------------------------- // if (prunes && PruneControlsEnabled) { // if (head.pruned || getHead() != null) { // head.pruneControl.update(onScreenScale); // mControlPoints[CPruneHead] = head.pruneControl; // } else // mControlPoints[CPruneHead] = null; // if (tail.pruned || getTail() != null) { // tail.pruneControl.update(onScreenScale); // mControlPoints[CPruneTail] = tail.pruneControl; // } else // mControlPoints[CPruneTail] = null; // } else { // mControlPoints[CPruneHead] = null; // mControlPoints[CPruneTail] = null; // } return mControlPoints; } /** This cleaup task can run so often, we put it right on the LWLink to prevent * all the extra new object creation. If the endpoints of the link have * been reparented, this will make sure we get reparented if need be. */ public void run() { if (isDeleted()) return; if (isDataLink()) { if (!head.hasNode() || !tail.hasNode()) { delete(); return; } } reparentBasedOnEndpoints(); // this is overkill, and could / should be re-implemented here // to be much faster and cleaner, but it should get the job done. // (e.g., only one call that ensures a re-ordering over both endpoints at once) if (head.hasNode() && head.node.getLayer() == getLayer()) LWContainer.ensureLinkPaintsOverAllAncestors(this, head.node); if (tail.hasNode() && tail.node.getLayer() == getLayer()) LWContainer.ensureLinkPaintsOverAllAncestors(this, tail.node); } public boolean isDataLink() { return hasFlag(Flag.DATA_LINK); } public boolean isDataCountLink() { return hasFlag(Flag.DATA_COUNT); } private void ensureDataBitsSet(String relation) { if (relation != null) { setFlag(Flag.DATA_LINK); if (relation.startsWith("COUNT:")) setFlag(Flag.DATA_COUNT); } } public void setAsDataLink(String relationship) { ensureDataBitsSet(relationship); addDataValue("$Related", relationship); //setNotes("Related: " + relationship); } @Override public void XML_completed(Object context) { super.XML_completed(context); ensureDataBitsSet(getDataValue("$Related")); // We also check for @DataLink as temporary backward-compat // if (hasDataKey("@DataLink") || hasDataKey("$Related")) // setFlag(Flag.DATA_LINK); } /** @return true if we reparented */ // TODO: if the link has been manually grouped during this action, do NOT reparent it at all... private boolean reparentBasedOnEndpoints() { final LWComponent commonAncestor = findCommonEndpointAncestor(); if (commonAncestor == null || commonAncestor == parent) { if (DEBUG.LINK) out("SAME COMMON ANCESTOR: " + commonAncestor); //if (DEBUG.LINK) System.out.println("SAME COMMON ANCESTOR: " + this + "; " + commonAncestor); return false; } // if already at a "top" level, which now includes layers, we don't need to // reparent ourself -- all top levels are considered equal if (getParent().isTopLevel() && commonAncestor.isTopLevel()) return false; if (DEBUG.LINK) out(Util.TERM_GREEN + "REPARENTING TO NEW COMMON ANCESTOR: " + commonAncestor + Util.TERM_CLEAR); commonAncestor.addChild(this); return true; } private LWComponent findCommonEndpointAncestor() { if (head.node == null) { if (tail.node == null) return null; else return tail.node.getParent(); } else if (tail.node == null) { if (head.node == null) return null; else return head.node.getParent(); } // These are some quick-check cases we can test // w/out having to generate the ancestor lists: if (head.node.parent == tail.node.parent) return head.node.parent; if (head.node.parent == tail.node.parent.parent) return head.node.parent; if (tail.node.parent == head.node.parent.parent) return tail.node.parent; // Okay, no success yet, generate the lists: final List<LWComponent> headAncestors = head.node.getAncestors(); final List<LWComponent> tailAncestors = tail.node.getAncestors(); if (DEBUG.PARENTING && DEBUG.META) out(Util.TERM_RED + "checking for common ancestor:" + "\n\t in link: " + this + "\n\t head: " + head.node + "\n\t tail: " + tail.node + "\n\theadAncestors: " + headAncestors + "\n\ttailAncestors: " + tailAncestors + Util.TERM_CLEAR); for (LWComponent ha : headAncestors) for (LWComponent ta : tailAncestors) if (ta == ha) return ta; Util.printStackTrace("failed to find common ancestor:" + "\n\t in link: " + this + "\n\t head: " + head.node + "\n\t tail: " + tail.node + "\n\theadAncestors: " + headAncestors + "\n\ttailAncestors: " + tailAncestors); return null; } /** @return true if either of the links endpoints are connected */ public boolean isBound() { return head.isConnected() || tail.isConnected(); } public boolean isCurved() { return mCurveControls > 0; } /** * This sets a link's curve controls to 0, 1 or 2 and manages * switching betweens states. 0 is straignt, 1 is quad curve, * 2 is cubic curve. Also called by persistance to establish * curved state of a link. */ private static final boolean CacheCurves = true; // needs to be true undo to work perfectly for curves public void setControlCount(int newControlCount) { //System.out.println(this + " setting CONTROL COUNT " + newControlCount); if (newControlCount > 2) throw new IllegalArgumentException("LWLink: max 2 control points " + newControlCount); if (mCurveControls == newControlCount) return; // Note: Float.MIN_VALUE is used as a special marker // to say that that control point hasn't been initialized // yet. if (mCurveControls == 0 && newControlCount == 1) { if (CacheCurves && mQuad != null) { mCurve = mQuad; // restore old curve } else { mQuad = new QuadCurve2D.Float(); mCurve = null; // mark for init mQuad.ctrlx = NEEDS_DEFAULT; mQuad.ctrly = NEEDS_DEFAULT; } } else if (mCurveControls == 0 && newControlCount == 2) { if (CacheCurves && mCubic != null) { mCurve = mCubic; // restore old curve } else { mCubic = new CubicCurve2D.Float(); mCurve = null; // mark for init mCubic.ctrlx1 = NEEDS_DEFAULT; mCubic.ctrlx2 = NEEDS_DEFAULT; } } else if (mCurveControls == 1 && newControlCount == 2) { // adding one (up from QuadCurve to CubicCurve) if (CacheCurves && mCubic != null) { mCurve = mCubic; // restore old cubic curve if had one } else { mCubic = new CubicCurve2D.Float(); mCurve = null; // mark for init mCubic.ctrlx2 = NEEDS_DEFAULT; mCubic.ctrly2 = NEEDS_DEFAULT; if (CacheCurves) { // if new & had quadCurve, keep the old ctrl point as one of the new ones mCubic.ctrlx1 = mQuad.ctrlx; mCubic.ctrly1 = mQuad.ctrly; } else { mCubic.ctrlx1 = NEEDS_DEFAULT; mCubic.ctrly1 = NEEDS_DEFAULT; } } } else if (mCurveControls == 2 && newControlCount == 1) { // removing one (drop from CubicCurve to QuadCurve) if (CacheCurves && mQuad != null) { // restore old quad curve if had one mCurve = mQuad; } else { mQuad = new QuadCurve2D.Float(); if (CacheCurves) { mCurve = mQuad; mQuad.ctrlx = mCubic.ctrlx1; mQuad.ctrly = mCubic.ctrly1; } else { mQuad.ctrlx = NEEDS_DEFAULT; mQuad.ctrly = NEEDS_DEFAULT; mCurve = null; } } } else { // this means we're straight (newControlCount == 0) mCurve = null; } Object old = new Integer(mCurveControls); mCurveControls = newControlCount; //this.mControlPoints = new LWSelection.Controller[MAX_CONTROL]; mRecompute = true; notify(LWKey.LinkShape, old); } /** for persistance */ public int getControlCount() { return mCurveControls; } /** for persistance */ public Point2D getCtrlPoint0() { if (mCurveControls == 0) return null; else if (mCurveControls == 2) return mCubic.getCtrlP1(); else return mQuad.getCtrlPt(); } /** for persistance */ public Point2D getCtrlPoint1() { return (mCurveControls == 2) ? mCubic.getCtrlP2() : null; } /** for persistance and ControlListener */ public void setCtrlPoint0(Point2D point) { setCtrlPoint0((float) point.getX(), (float) point.getY()); } public void setCtrlPoint0(float x, float y) { if (Float.isNaN(x)) { Log.warn("setCtrlPoint0/x; NaN not allowed"); x = NEEDS_DEFAULT; } if (Float.isNaN(y)) { Log.warn("setCtrlPoint0/y; NaN not allowed"); y = NEEDS_DEFAULT; } //IS THIS WORKING FOR UNDO??? if (mCurveControls == 0) { setControlCount(1); if (DEBUG.UNDO) System.out.println("implied curved link by setting control point 0 " + this); } Object old; if (mCurveControls == 2) { old = new Point2D.Float(mCubic.ctrlx1, mCubic.ctrly1); mCubic.ctrlx1 = x; mCubic.ctrly1 = y; } else { old = new Point2D.Float(mQuad.ctrlx, mQuad.ctrly); mQuad.ctrlx = x; mQuad.ctrly = y; } mRecompute = true; notify(Key_Control_0, old); } /** for persistance and ControlListener */ public void setCtrlPoint1(Point2D point) { setCtrlPoint1((float) point.getX(), (float) point.getY()); } public void setCtrlPoint1(float x, float y) { if (Float.isNaN(x)) { Log.warn("setCtrlPoint1/x; NaN not allowed"); x = NEEDS_DEFAULT; } if (Float.isNaN(y)) { Log.warn("setCtrlPoint1/y; NaN not allowed"); y = NEEDS_DEFAULT; } if (mCurveControls < 2) { setControlCount(2); if (DEBUG.UNDO) System.out.println("implied cubic curved link by setting a control point 1 " + this); } Object old = new Point2D.Float(mCubic.ctrlx2, mCubic.ctrly2); mCubic.ctrlx2 = x; mCubic.ctrly2 = y; mRecompute = true; notify(Key_Control_1, old); } @Override protected void removeFromModel() { if (head.pruned) toggleHeadPrune(); if (tail.pruned) toggleTailPrune(); super.removeFromModel(); if (head.hasNode()) head.node.removeLinkRef(this); if (tail.hasNode()) tail.node.removeLinkRef(this); } @Override protected void restoreToModel() { super.restoreToModel(); if (head.hasNode()) head.node.addLinkRef(this); if (tail.hasNode()) tail.node.addLinkRef(this); mRecompute = true; // for some reason cached label position is off on restore } private LWComponent parentInParentChildLink() { if (head.node == null || tail.node == null) return null; if (head.node.getParent() == tail.node) return tail.node; if (tail.node.getParent() == head.node) return head.node; return null; } /** Is this link between a parent and a child? */ public boolean isParentChildLink() { if (head.node == null || tail.node == null) return false; return head.node.getParent() == tail.node || tail.node.getParent() == head.node; //return parentInParentChildLink() != null; } /** @return true of this link has any links to the given component, or has it as one of our endpoints. */ @Override public boolean isConnectedTo(LWComponent c) { if (c == null) return false; else return hasEndpoint(c) || super.isConnectedTo(c); } public boolean hasEndpoint(LWComponent c) { return c != null && (head.node == c || tail.node == c); } /** @return the endpoint of this link that is not the given source */ public LWComponent getFarPoint(LWComponent source) { if (head.node == source) return tail.node; else if (tail.node == source) return head.node; else throw new IllegalArgumentException("bad farpoint: " + source + " not connected to " + this); } /** @return the endpoint of this link that is not the given source, if congruent with the arrow directionality */ public LWComponent getFarNavPoint(LWComponent source) { int arrows = getArrowState(); if (getHead() == source) { if (arrows == ARROW_NONE || (arrows & ARROW_TAIL) != 0) return getTail(); } else if (getTail() == source) { if (arrows == ARROW_NONE || (arrows & ARROW_HEAD) != 0) return getHead(); } else throw new IllegalArgumentException("bad farpoint: " + source + " not connected to " + this); return null; } /** * This is a nested link (a visual characteristic) if it's not a curved link, and: both ends * of this link in the same LWNode parent, or it's a parent-child * link, or it's parent is a LWNode. */ public boolean isNestedLink() { if (isCurved()) return false; if (head.node == null || tail.node == null) return getParent() instanceof LWNode; if (head.node.getParent() == tail.node || tail.node.getParent() == head.node) return true; return head.node.getParent() == tail.node.getParent() && head.node.getParent() instanceof LWNode; } public void mouseOver(MapMouseEvent e) { if (e.getViewer().getZoomFactor() > ICON_BLOCK_LOD_ZOOM && mIconBlock.isShowing()) mIconBlock.checkAndHandleMouseOver(e); } private class SegIterator implements Iterator<Line2D.Float>, Iterable<Line2D.Float> { private int idx; private final Line2D.Float seg = new Line2D.Float(); public SegIterator() { // start with first point of first segment pre-loaded as last point in // the cached segment //seg.x2 = mPoints[0]; //seg.y2 = mPoints[1]; //idx = 2; seg.x2 = head.x; seg.y2 = head.y; idx = 0; } public boolean hasNext() { return idx < mLastPoint; } public Line2D.Float next() { seg.x1 = seg.x2; seg.y1 = seg.y2; seg.x2 = mPoints[idx++]; seg.y2 = mPoints[idx++]; return seg; } public void remove() { throw new UnsupportedOperationException(); } public Iterator<Line2D.Float> iterator() { return this; } } @Override protected boolean intersectsImpl(Rectangle2D mapRect) { if (mRecompute) computeLink(); final Rectangle2D localRect = transformMapToZeroRect(mapRect); if (DEBUG.LINK && mXMLRestoreUnderway) { System.out.println("TRANSFORMED " + this); if (!localRect.equals(mapRect)) System.out.println("\t" + Util.fmt(mapRect) + " to:" + "\n\t" + Util.fmt(localRect)); } // final Rectangle2D localRect; // if (getParent() instanceof LWMap) { // // checking parent is an optimization: transforming map to local rect is a noop // // if we're a direct child of the map // localRect = mapRect; // } else { // // As interectsImpl is called with a rectangle in map coordinates, we transform // // it to local (parent) coordinates first, before checking the segments, which // // all have local coordinates. // // final Rectangle2D tmpRect = (Rectangle2D) mapRect.clone(); // // //localRect = transformMapToParentLocalRect(tmpRect); // // localRect = transformMapToZeroRect(tmpRect); // // Recall that the zero-rect for a link is actually the parent, // // so this is really the "local" rect. // localRect = transformMapToZeroRect(mapRect, null); // if (DEBUG.LINK && mXMLRestoreUnderway) { // System.out.println("TRANSFORMED " + this); // if (!localRect.equals(mapRect)) // System.out.println("\t" + Util.fmt(mapRect) + " to:" // + "\n\t" + Util.fmt(localRect)); // } // } // // if (! localRect.intersects(getX(), getY(), getWidth(), getHeight())) { // // // fast-reject on pre-computed bounding box // // return false; // // } if (mCurve != null) { for (Line2D seg : new SegIterator()) if (seg.intersects(localRect)) return true; } else { if (localRect.intersectsLine(mLine)) return true; } if (mIconBlock.isShowing() && mIconBlock.intersects(localRect)) return true; if (labelBox != null && hasLabel()) return labelBox.boxIntersects(localRect); else return false; } /** compute shortest distance from the link to the given point (the nearest segment of the line) */ public float distanceToEdgeSq(float x, float y) { double minDistSq = Float.MAX_VALUE; if (mCurve != null) { double segDistSq; for (Line2D seg : new SegIterator()) { segDistSq = seg.ptSegDistSq(x, y); if (segDistSq < minDistSq) minDistSq = segDistSq; } } else { minDistSq = mLine.ptSegDistSq(x, y); } return (float) minDistSq; } @Override protected boolean containsImpl(float x, float y, PickContext pc) { // link coordinates are all parent-local, so containsImpl is passing us a coordinate // in the space of our parent. // if (head.isPruned() || tail.isPruned()) // return false; final float lx = getX(); final float ly = getY(); if (x < lx || y < ly || x > lx + getWidth() || y > ly + getHeight()) { // fast reject on pre-computed bounding box (which already includes stroke width) return false; } else { return pickDistance(x, y, pc) == 0 ? true : false; } } /** * @return values: * 0 means a direct hit on the line or label. Return values greater than 0 represent the square of the distance from * the passed in coordinate to the stroke of the link (ignoring any label box) */ @Override protected float pickDistance(float x, float y, PickContext pc) { if (mRecompute) computeLink(); if (isCurrentlyPruned()) return pickPruneDistance(x, y, pc); else return pickLineDistance(x, y, pc); } private float pickPruneDistance(float x, float y, PickContext pc) { final float distSq; if (head.pruned) distSq = (float) head.distanceSq(x, y); else if (tail.pruned) distSq = (float) tail.distanceSq(x, y); else distSq = -1; if (distSq <= PruneDotHitRadiusSq) return 0; else return distSq; } private float pickLineDistance(float x, float y, PickContext pc) { final float hitDist = getStrokeWidth() / 2f; final float hitDistSq = hitDist * hitDist; float minDistSq = Float.MAX_VALUE; if (mCurve != null) { float distSq; // Check the distance from all the segments in the flattened curve for (Line2D seg : new SegIterator()) { distSq = (float) seg.ptSegDistSq(x, y); if (distSq <= hitDistSq) return 0; else if (distSq < minDistSq) minDistSq = distSq; } } else { final float distSq = (float) mLine.ptSegDistSq(x, y); if (distSq <= hitDistSq) return 0; else minDistSq = distSq; } if (!isNestedLink()) { if (mIconBlock.contains(x, y)) return 0; else if (hasLabel() && labelBox != null && labelBox.boxContains(x, y)) return 0; } return minDistSq - hitDistSq; } public boolean isConnected() { return head.isConnected() || tail.isConnected(); } // public boolean isOrdered() // { // return this.ordered; // } // public void setOrdered(boolean ordered) // { // this.ordered = ordered; // } public int getWeight() { return (int) (getStrokeWidth() + 0.5f); } public void setWeight(int w) { setStrokeWidth((float)w); } public void setStrokeWidth(float w) { if (w <= 0f) w = 0.1f; super.setStrokeWidth(w); } public int incrementWeight() { //this.weight += 1; //return this.weight; setStrokeWidth(getStrokeWidth()+1); return getWeight(); } @Override public void setLocation(float x, float y) { final float dx = x - getX(); final float dy = y - getY(); if (DEBUG.CONTAINMENT || DEBUG.LINK) out(String.format(" setLocation %+.1f,%+.1f", x, y)); translate(dx, dy); } @Override protected void takeLocation(float x, float y) { Log.debug("takeLocation on Link: " + this); setLocation(x, y); } private void undoLocation(float x, float y) { final float dx = x - getX(); final float dy = y - getY(); translateAllPoints(dx, dy, true); } /** * Any free points on the link get translated by the given dx/dy. This means as any * unattached endpoints, as well as any control points if it's a curved link. If * both ends of this link are connected and it has no control points (it's straight, * not curved) this call has no effect. */ @Override public void translate(float dx, float dy) { translateAllPoints(dx, dy, false); } private void translateAllPoints(float dx, float dy, boolean onUndo) { if (DEBUG.CONTAINMENT) out(String.format(" map translate %+.1f,%+.1f onUndo=%s", dx, dy, onUndo)); if (head.node == null) setHeadPoint(head.x + dx, head.y + dy); if (tail.node == null) setTailPoint(tail.x + dx, tail.y + dy); if (onUndo) { if (DEBUG.LINK) out("skipping ctrl point translate on undo; relying on ctrl point undo events"); // Note that if the link was parented to something in the model when the original change // to the control points took place (e.g., if a newly created group were to grab the link // at created before the new group itself was in the model), this won't work, because // there will have been no recorded undo events to handle this! This is why links are // only added to newly created groups via our standard cleanup task, even tho this isn't // especially efficient if creating a large group, tho this is not a frequent enougn // operation to make this of real concern. } else { // these will have been undone by their own undo events: if (mCurveControls == 1) { setCtrlPoint0(mQuad.ctrlx + dx, mQuad.ctrly + dy); } else if (mCurveControls == 2) { setCtrlPoint0(mCubic.ctrlx1 + dx, mCubic.ctrly1 + dy); setCtrlPoint1(mCubic.ctrlx2 + dx, mCubic.ctrly2 + dy); } } } // private LWComponent firstScaledParent() { // for (LWComponent c : getAncestors()) { // TODO: this is slow // if (c.getScale() != 1.0) // return c; // } // //Util.printStackTrace("found no scaled parent " + this); // return getParent(); // } // private void scaleCoordinatesRelativeToParent(final float scale) // { // if (scale == 1.0) // return; // if (Float.isNaN(scale) || Float.isInfinite(scale)) { // Util.printStackTrace("bad scale: " + scale + " in " + this); // } // if (oldParent == this) { // // this means we were just created: we can ignore this // oldParent = null; // return; // } // if (oldParent == null) { // if (DEBUG.WORK) Util.printStackTrace("scaleCoords: no old parent (ok on creates): " + this); // return; // } // //final LWComponent scaledParent = firstScaledParent(); // final LWComponent scaledParent = oldParent; // final float px = scaledParent.getMapX(); // final float py = scaledParent.getMapY(); // if (DEBUG.WORK) out("scaleCoords: deltaScale=" + scale + "; scaledParent=" + scaledParent); // //out("px=" + px + ". py=" + py); // if (head.node == null) // setHeadPoint(px + (head.x - px) * scale, // py + (head.y - py) * scale); // if (tail.node == null) // setTailPoint(px + (tail.x - px) * scale, // py + (tail.y - py) * scale); // if (mCurveControls == 1) { // setCtrlPoint0(px + (mQuad.ctrlx - px) * scale, // py + (mQuad.ctrly - py) * scale); // } else if (mCurveControls == 2) { // setCtrlPoint0(px + (mCubic.ctrlx1 - px) * scale, // py + (mCubic.ctrly1 - py) * scale); // setCtrlPoint1(px + (mCubic.ctrlx2 - px) * scale, // py + (mCubic.ctrly2 - py) * scale); // } // } /** called by LWComponent.updateConnectedLinks to let * us know something we're connected to has moved, * and thus we need to recompute our drawn shape. * @param movingSrc - the moving component that originated this update -- will be null if not the result of a location change * @param end - the endpoint that's changing / moving */ void notifyEndpointMoved(LWComponent movingSrc, LWComponent end) { final boolean wasDirty = this.mRecompute; // this is for debug only: remove eventually if (mRecompute || (movingSrc != null && hasAncestor(movingSrc) && end.hasAncestor(movingSrc))) { // we can skip the update: the link and the endpoint are both moving // inside a collective parent context (or we already marked) } else { mRecompute = true; } if (DEBUG.CONTAINMENT) { if (DEBUG.LINK&&DEBUG.WORK) { System.out.format("notifyEndpointMoved %-70s movingSrc=%s end=%s wasDirty=%s nowMarked=%s\n", this, movingSrc, end, wasDirty, mRecompute); } else { if (!wasDirty && !mRecompute) System.err.print("|"); else if (!wasDirty && mRecompute) System.err.print(";"); else System.err.print(":"); } //if (end instanceof LWLink) Util.printStackTrace("notifyEndpointMoved " + this); //Util.printClassTrace("tufts.vue", "notifyEndpointMoved " + this); } } // void notifyEndpointReparented(LWComponent end) // { // //Util.printStackTrace("ENDPOINT REPARENTED: " + this + "; which=" + end); // //this.endpointReparented = true; // addCleanupTask(this); // } void notifyEndpointHierarchyChanged(LWComponent end) { //Util.printStackTrace("ENDPOINT REPARENTED: " + this + "; which=" + end); //this.endpointReparented = true; addCleanupTask(this); // in case of links to links, we can reasonable get cascading cleanup tasks // (tasks that are added while tasks are being run), but the UndoManager // doesn't safely support that yet, so this is a workaround for now. // if (head.node instanceof LWLink) // head.node.addCleanupTask((LWLink)head.node); // if (tail.node instanceof LWLink) // tail.node.addCleanupTask((LWLink)tail.node); } // // private double oldMapScale = 1.0; // // private LWComponent oldParent = this; // // @Override // // public void notifyHierarchyChanging() // // { // // super.notifyHierarchyChanging(); // // oldParent = getParent(); // // oldMapScale = getMapScale(); // // if (DEBUG.WORK )out("NH CHANGING: curScale=" + oldMapScale); // // } // @Override // public void notifyHierarchyChanged() { // super.notifyHierarchyChanged(); // if (LOCAL_LINKS) // return; // final double newScale = getMapScale(); // final double deltaScale = newScale / oldMapScale; // if (DEBUG.WORK) { // out("NH CHANGED: oldScale=" + oldMapScale); // out("NH CHANGED: newScale=" + newScale); // out("NH CHANGED: deltaScale=" + deltaScale); // out("NH CHANGED: ANCESTORS:"); // for (LWComponent c : getAncestors()) // System.out.format("\tscale %.2f %.2f in %s\n", c.getScale(), c.getMapScale(), c); // } // scaleCoordinatesRelativeToParent( (float) deltaScale ); // } // @Override // protected void notifyMapScaleChanged(double oldParentMapScale, double newParentMapScale) { // final double deltaScale = newParentMapScale / oldParentMapScale; // out("mapScaleChanged: " + deltaScale); // // wait for setLocations... // // addCleanupTask(new Runnable() { public void run() { // // }}); // } // /** We've been notified that our absolute location should change by the given map dx/dy */ // @Override // protected void notifyMapLocationChanged(double mdx, double mdy) { // super.notifyMapLocationChanged(mdx, mdy); // if (DEBUG.CONTAINMENT) System.out.println // (String.format("notifyMapLocationChanged %+.1f,%+.1f%s", // mdx, // mdy, // LOCAL_LINKS ? " (ignored:local-link-impl) " : " ") // + this); // if (LOCAL_LINKS) return; // translate((float)mdx, (float)mdy); // } private void initCurveControlPoints() { //------------------------------------------------------- // INTIALIZE CONTROL POINTS & CURVE ALIAS //------------------------------------------------------- // Rely on the old actual values to CONNECTION points, previously computed in mLine // and centerX/centerY. // Note that this is still very imperfect, as when we move from a line to a // curve, the connection points can change dramatically, so using the // current axis is limited. Unfortunately, we can't know the new axis // until, of course, we first place the control points *somewhere*. final float axisLen = lineLength(mLine); final float axisOffset; if (DEBUG.LINK) out("AXIS LEN " + axisLen + " for line " + Util.out(mLine) + " center currently " + mCenterX + "," + mCenterY); if (DEBUG.LINK) out("rotHeadINIT " + head.rotation + " rotTailINIT " + tail.rotation); if (mCurveControls == 2) axisOffset = axisLen / 4; else axisOffset = axisLen / 3; // do this via a log: grows slowing with length increaase //out("axisLen " + axisLen + " offset " + axisOffset); final AffineTransform centerLeft = AffineTransform.getTranslateInstance(mCenterX, mCenterY); //double deltaX = Math.abs(head.x - tail.x); //double deltaY = Math.abs(head.y - tail.y); int existingCurveCount = 0; if (head.hasNode()) { // subtract one so we don't count us -- the new curved link existingCurveCount = head.node.countCurvedLinksTo(tail.node) - 1; } boolean reverse = existingCurveCount % 2 == 1; final int further = 1 + existingCurveCount / 2; if (tail.x > head.x) { centerLeft.rotate(tail.rotation); //centerLeft.rotate(mCurveControls == 2 ? tail.rotation : head.rotation); } else { centerLeft.rotate(head.rotation); //centerLeft.rotate(mCurveControls == 2 ? tail.rotation : head.rotation); } if (reverse) centerLeft.translate(+axisOffset * further, 0); else centerLeft.translate(-axisOffset * further, 0); final AffineTransform centerRight = new AffineTransform(centerLeft); centerRight.translate(axisOffset*2,0); final Point2D.Float p = new Point2D.Float(); if (mCurveControls == 2) { mCurve = mCubic; if (mCubic.ctrlx1 == NEEDS_DEFAULT) { centerLeft.transform(p,p); mCubic.ctrlx1 = p.x; mCubic.ctrly1 = p.y; } if (mCubic.ctrlx2 == NEEDS_DEFAULT) { p.x = p.y = 0; centerRight.transform(p,p); mCubic.ctrlx2 = p.x; mCubic.ctrly2 = p.y; } } else { mCurve = mQuad; if (mQuad.ctrlx == NEEDS_DEFAULT) { centerLeft.transform(p,p); mQuad.ctrlx = p.x; mQuad.ctrly = p.y; } } } /** @return the shape in it's local context (which for links, is it's parent) * note that mCurve/mLine are zero based within their parent: the upper-left x/y of this shape * is not actually guaranteed to be 0,0 for links. */ @Override public Shape getZeroShape() { if (mRecompute) computeLink(); if (mCurveControls > 0) return mCurve; else return mLine; } /** @return the parent based bounds -- for links this is the local component x,y width,height (no scale: links can't be scaled). * Note that for links this is the same as getZeroBounds() */ @Override public Rectangle2D.Float getLocalBounds() { if (mRecompute) computeLink(); return new Rectangle2D.Float(getX(), getY(), getWidth(), getHeight()); } /** overriden just to make sure the link is computed before returning a result from super.getMapBounds() */ @Override public Rectangle2D.Float getMapBounds() { if (mRecompute) computeLink(); return super.getMapBounds(); } /** @return "zero-based" bounds for the link, which for links are the same as it's local bounds: the bounds in it's parent * So this just returns getLocalBounds(), as these are the same for links. */ @Override protected Rectangle2D.Float getZeroBounds() { return getLocalBounds(); } /** @return getMapBounds() -- border (stroke) already included for links */ public Rectangle2D.Float getBorderBounds() { return getMapBounds(); } /** @return getMapBounds() -- border (stroke) + any text label already included for links */ @Override public Rectangle2D.Float getPaintBounds() { return getMapBounds(); } /** @return getLocalBounds() -- border (stroke) already included for links */ @Override public Rectangle2D.Float getLocalBorderBounds() { return getLocalBounds(); } /** @return the current local paint bounds -- the bounds of the line/curve, plus any label box * This just makes sure we're computed, and returns getLocalBounds(), as there's no difference * between paint v.s. regular bounds for links. */ @Override public Rectangle2D.Float getLocalPaintBounds() { return getLocalBounds(); // final Rectangle2D.Float bounds = getLocalBounds(); // if (labelBox != null && hasLabel()) // bounds.add(labelBox.getBoxBounds()); // return bounds; } /** Makes sure the link is recomputed after label changes */ @Override void setLabel0(String newLabel, boolean setDocument) { super.setLabel0(newLabel, setDocument); if (mXMLRestoreUnderway) ; // do nothing else if (getParent() == null) mRecompute = true; // mark for later else computeLink(); // recompute now } /** @return a unmodified: leave us in the parent context; all link coordinates are relative to the parent */ @Override protected final AffineTransform transformDownA(AffineTransform a) { return a; } /** noop -- leave the GC in the parent context; all link coordinates are relative to the parent */ @Override protected final void transformDownG(final Graphics2D g) { // do nothing: link coordinate space is in it's parent } /** OPTIMIZATION FOR LWLink override */ @Override protected final Rectangle2D transformMapToZeroRect(Rectangle2D mapRect) { return parent.transformMapToZeroRect(mapRect); } // /** OPTIMIZATION FOR LWLink override */ // // links use this for doing intersction with a map rect (for rect picking & clipping) // @Override // protected final Rectangle2D transformMapToZeroRect(Rectangle2D mapRect, Rectangle2D zeroRect) { // if (parent instanceof LWMap) { // // This is an optimization we'll want to remove if we ever // // embed maps in maps. // return mapRect; // } else { // // note this is being called on PARENT, not SUPER // return parent.transformMapToZeroRect(mapRect, zeroRect); // } // } // // this only an optimization // /** perform parent.transformZero */ // @Override // public void transformZero(Graphics2D g) { // if (parent != null) // parent.transformZero(g); // } // // This only an optimization // /** as all link coordinates are relative to their parent, this just calls // parent.transformMapToZeroPoint */ // @Override // public Point2D transformMapToZeroPoint(Point2D.Float mapPoint, Point2D.Float nodePoint) { // return parent.transformMapToZeroPoint(mapPoint, nodePoint); // } // /** @return parent.getZeroTransform() */ // @Override // public AffineTransform getZeroTransform() { // return parent.getZeroTransform(); // } private final float[] intersection = new float[2]; // result cache for intersection coords // void markAsComputed() { // mRecompute = false; // } /** * Compute the endpoints of this link based on the edges of the shapes we're * connecting. To do this we draw a line from the center of one shape to the center * of the other, and set the link endpoints to the places where mLine crosses the * edge of each shape. If one of the shapes is a straight line, or for some reason * a shape doesn't have a facing "edge", or if anything unpredicatable happens, we * just leave the connection point as the center of the object. * * We also compute and cache rotation values for normalizing the link * to vertical (or it's control lines to vertical if a curve) so we * can easily move along these lines to provide control points (Controllers) for the link. */ private void computeLink() { if (mXMLRestoreUnderway) { if (DEBUG.LINK) Util.printStackTrace("computeLink attempted during restore " + this); return; } if (isStyle() && getParent() == null) { // Don't recompute w/out a parent -- e.g., link style holders never need to recompute. // Will this break cut/copy/paste/duplicate? // No, it doesn't, but it DOES break creating at runtime a network of links & nodes // ad-hoc and then adding it to a map. // So now: only if we're specially marked as a style object, and we also don't have a parent. return; } mRecompute = false; if (DEBUG.LINK) { //Util.printStackTrace("computeLink " + this); System.out.println("computeLink " + this); } if (mCurveControls > 0 && mCurve == null) initCurveControlPoints(); // Start with head & tail locations at center of the object at each endpoint. // Note that links are computed in entirely absolute map coordinates. To // compute the actual connection point, we pass the local transform for the // endpoint to computeIntersection, which uses that to produce a traversable // flattened path transformed down to the local scale of that endpoint. final LWContainer parent = getParent(); if (head.hasNode()) { // If an endpoint is a link, make sure it's currently computed so we know exactly // where it's center is. Since we've already cleared our mRecompute bit, we're // safe against link-loops, tho we want to be careful not to create link networks // that create unresolvable dependencies. (E.g., a straight link is linked to a // curved link: don't let the either of the curved link's endpoints connect back to // the straight link). We prevent these kinds of links in LinkTool. If one is // ever created, things don't actually completely fail since we've built in loop // protection, but the links never reach a final state: they're constantly // recomputing themseleves every single time something needs to know where the link // is (e.g, a pick or a paint). if (!mXMLRestoreUnderway && head.node instanceof LWLink && ((LWLink)head.node).mRecompute) ((LWLink)head.node).computeLink(); // This will store the parent-local center x/y in head: head.node.getLinkConnectionCenterRelativeTo(head.getPoint(), parent); } if (tail.hasNode()) { // see above comment if (!mXMLRestoreUnderway && tail.node instanceof LWLink && ((LWLink)tail.node).mRecompute) ((LWLink)tail.node).computeLink(); // This will store the parent-local center x/y in tail: tail.node.getLinkConnectionCenterRelativeTo(tail.getPoint(), parent); } // Note, if what's at the endpoint we're connecting to is a LWLink, we do NOT // bother to establish a connection at the nearest point -- we leave the // connection at the center point of LWLink. (For curves this is defined by the // midpoint of the first sub-division -- the same place we put the label if // there is one). //----------------------------------------------------------------------------- // PROCESS THE HEAD END //----------------------------------------------------------------------------- final Shape headShape; final AffineTransform headTransform; if (head.node == null || head.node instanceof LWLink) { headShape = null; headTransform = null; } else { // use raw/zero shape because we use the local transform in computeIntersection headShape = head.node.getZeroShape(); headTransform = head.node.getRelativeTransform(parent); // TODO PERFORMANCE: can we to use the same relative transform for the // connection center as for the node itself? I think so.... Is it safe to // rely on the ancestor-only relative transform here? Aren't we seeing // non-ancestor x-hierarhcy transforms in some cases? (groups/undo?) // Ideally, we'd have a getRelativeTransform which could be optimized for // ancestors, tho if it gets to the top w/out finding the desired ancestor, // it could construct a x-hierarchy relative transform from scratch as a // fallback. } if (headShape != null) { final float srcX, srcY; if (mCurveControls == 1) { srcX = mQuad.ctrlx; srcY = mQuad.ctrly; } else if (mCurveControls == 2) { srcX = mCubic.ctrlx1; srcY = mCubic.ctrly1; } else { srcX = tail.x; srcY = tail.y; } final float[] result = VueUtil.computeIntersection(head.x, head.y, srcX, srcY, headShape, headTransform, intersection, 1); // If intersection fails for any reason, leave endpoint as center of object at the head. if (result != VueUtil.NoIntersection) { head.x = intersection[0]; head.y = intersection[1]; } } //----------------------------------------------------------------------------- // PROCESS THE TAIL END //----------------------------------------------------------------------------- final Shape tailShape; final AffineTransform tailTransform; if (tail.node == null || tail.node instanceof LWLink) { tailShape = null; tailTransform = null; } else { // use zero/raw shape because we use the relative transform in computeIntersection tailShape = tail.node.getZeroShape(); tailTransform = tail.node.getRelativeTransform(parent); } if (tailShape != null) { final float srcX, srcY; if (mCurveControls == 1) { srcX = mQuad.ctrlx; srcY = mQuad.ctrly; } else if (mCurveControls == 2) { srcX = mCubic.ctrlx2; srcY = mCubic.ctrly2; } else { srcX = head.x; srcY = head.y; } final float[] result = VueUtil.computeIntersection(srcX, srcY, tail.x, tail.y, tailShape, tailTransform, intersection, 1); // If intersection fails for any reason, leave endpoint as center of object at tail. if (result != VueUtil.NoIntersection) { tail.x = intersection[0]; tail.y = intersection[1]; } } mCenterX = head.x - (head.x - tail.x) / 2; mCenterY = head.y - (head.y - tail.y) / 2; mLine.setLine(head.x, head.y, tail.x, tail.y); // length is currently always set to the length of the straight line: curve length not currently computed mLength = lineLength(mLine); Rectangle2D.Float curveBounds = null; if (mCurveControls > 0) curveBounds = computeCurvedLink(); //--------------------------------------------------------------------------------------------------- // Compute rotations for arrows or for moving linearly along the link //--------------------------------------------------------------------------------------------------- if (DEBUG.LINK && DEBUG.META) out("head " + head.x+","+head.y + " tail " + tail.x+","+tail.y + " line " + Util.out(mLine)); if (mCurveControls == 1) { head.rotation = computeVerticalRotation(head.x, head.y, mQuad.ctrlx, mQuad.ctrly); tail.rotation = computeVerticalRotation(tail.x, tail.y, mQuad.ctrlx, mQuad.ctrly); } else if (mCurveControls == 2) { head.rotation = computeVerticalRotation(head.x, head.y, mCubic.ctrlx1, mCubic.ctrly1); tail.rotation = computeVerticalRotation(tail.x, tail.y, mCubic.ctrlx2, mCubic.ctrly2); } else { head.rotation = computeVerticalRotation(mLine.x1, mLine.y1, mLine.x2, mLine.y2); tail.rotation = head.rotation + Math.PI; // can just flip head rotation: add 180 degrees } if (DEBUG.LINK && DEBUG.META) out("rotHead0 " + head.rotation + " rotTail0 " + tail.rotation); //---------------------------------------------------------------------------------------- // //------------------------------------------------------- // // Old-style prune controls // //------------------------------------------------------- // float controlOffset = (float) HeadShape.getHeight() * 3; // //final int controlSize = 6; // //final double minControlSize = MapViewer.SelectionHandleSize / dc.zoom; // // can get zoom by passing into getControlPoints from MapViewer.drawSelection, // // which could then pass it to computeLink, so we could have it here... // final float minControlSize = 2; // fudged: ignoring zoom for now // final float room = mLength - controlOffset * 2; // if (room <= minControlSize*2) // controlOffset = mLength/3; // if (DEBUG.LINK && DEBUG.META) out("controlOffset " + controlOffset); // //if (room <= controlSize*2) // // controlOffset = mLength/2 - controlSize; // head.pruneCtrlOffset = controlOffset; // tail.pruneCtrlOffset = controlOffset; //---------------------------------------------------------------------------------------- layout(); // place the label / icons if we have any //---------------------------------------------------------------------------------------- // We set the size & location here so LWComponent.getBounds can do something // reasonable with us for computing/drawing a selection box, determining if we // intersect the drawing region, etc. //---------------------------------------------------------------------------------------- final Rectangle2D.Float bounds; if (mCurveControls > 0) { bounds = curveBounds; } else { bounds = new Rectangle2D.Float(); bounds.width = Math.abs(head.x - tail.x); bounds.height = Math.abs(head.y - tail.y); bounds.x = mCenterX - bounds.width/2; bounds.y = mCenterY - bounds.height/2; } if (getStrokeWidth() > 0) grow(bounds, getStrokeWidth() / 2f); if (labelBox != null && hasLabel()) bounds.add(labelBox.getBoxBounds()); if (mIconBlock.isShowing()) bounds.add(mIconBlock); // Record the size & location w/out triggering update events: setX(bounds.x); setY(bounds.y); takeSize(bounds.width, bounds.height); if (Util.isBadRect(bounds)) { Log.warn("bad bounds in computeLink: " + this); validateInitialValues(); } //if (DEBUG.LINK) System.out.println("computeLink " + this + " COMPLETED."); // if there are any links connected to this link, make sure they // know that this endpoint has moved. updateConnectedLinks(null); } private Rectangle2D.Float computeCurvedLink() { // We compute the bounds ourselves, as the default bounds fetchers for // QuadCurve2D/CubicCurve2D include the control points, and we want to // leave out the control points in computing our bounds. final Rectangle2D.Float bounds = new Rectangle2D.Float(head.x, head.y, 0, 0); final boolean badCurve; if (mCurveControls == 1) { if (false && (mArrowState.get() & ARROW_HEAD) != 0) { // This backs up the curve endpoint to the tail of the arrow // This will slightly move the curve, but it keeps the connection // to the arrow much cleaner. Point2D.Float hp = new Point2D.Float(); AffineTransform tx = new AffineTransform(); tx.setToTranslation(head.x, head.y); tx.rotate(head.rotation); tx.translate(0, HeadShape.getHeight()); tx.transform(hp, hp); mQuad.x1 = hp.x; mQuad.y1 = hp.y; } else { mQuad.x1 = head.x; mQuad.y1 = head.y; } mQuad.x2 = tail.x; mQuad.y2 = tail.y; // compute approximate on-curve "center" for label // We compute a line from the center of control line 1 to // the center of control line 2: that line segment is a // tangent to the curve who's center is on the curve. // (See QuadCurve2D.subdivide) float ctrlx1 = (mQuad.x1 + mQuad.ctrlx) / 2; float ctrly1 = (mQuad.y1 + mQuad.ctrly) / 2; float ctrlx2 = (mQuad.x2 + mQuad.ctrlx) / 2; float ctrly2 = (mQuad.y2 + mQuad.ctrly) / 2; mCurveCenterX = (ctrlx1 + ctrlx2) / 2; mCurveCenterY = (ctrly1 + ctrly2) / 2; if (badCurve = badCurve(mQuad)) Log.warn(this + "; bad curve: " + Util.fmt(mQuad)); if (IncludeControlPointsInBounds) bounds.add(mQuad.ctrlx, mQuad.ctrly); } else if (mCurveControls == 2) { mCubic.x1 = head.x; mCubic.y1 = head.y; mCubic.x2 = tail.x; mCubic.y2 = tail.y; // compute approximate on-curve "center" for label // (See CubicCurve2D.subdivide) float centerx = (mCubic.ctrlx1 + mCubic.ctrlx2) / 2; float centery = (mCubic.ctrly1 + mCubic.ctrly2) / 2; float ctrlx1 = (mCubic.x1 + mCubic.ctrlx1) / 2; float ctrly1 = (mCubic.y1 + mCubic.ctrly1) / 2; float ctrlx2 = (mCubic.x2 + mCubic.ctrlx2) / 2; float ctrly2 = (mCubic.y2 + mCubic.ctrly2) / 2; float ctrlx12 = (ctrlx1 + centerx) / 2; float ctrly12 = (ctrly1 + centery) / 2; float ctrlx21 = (ctrlx2 + centerx) / 2; float ctrly21 = (ctrly2 + centery) / 2; mCurveCenterX = (ctrlx12 + ctrlx21) / 2; mCurveCenterY = (ctrly12 + ctrly21) / 2; if (badCurve = badCurve(mCubic)) Log.warn(this + "; bad curve: " + Util.fmt(mCubic)); if (IncludeControlPointsInBounds) { // Add the centers of the two control lines, where we put the controllers. bounds.add((mCubic.ctrlx1 + head.x) / 2, (mCubic.ctrly1 + head.y) / 2); bounds.add((mCubic.ctrlx2 + tail.x) / 2, (mCubic.ctrly2 + tail.y) / 2); } } else badCurve = false; //--------------------------------------------------------------------------------------------------- // Compute length / segments //--------------------------------------------------------------------------------------------------- /* * For very fancy computation of a curve "center", use below * code and then walk the segments computing actual * length of curve, then walk again searching for * segment at middle of that distance... */ // Flatten the curve into a bunch of segments for hit detection. if (badCurve || mCurve.getBounds().isEmpty()) { if (!badCurve) Log.warn(this + "; empty curve " + Util.fmt(mCurve) + " " + mCurve.getBounds()); //tufts.Util.printStackTrace("empty curve " + mCurve + " " + mCurve.getBounds()); return bounds; } if (mPoints == null) mPoints = new float[16]; mLastPoint = 0; //out("LINE: " + Util.out(mLine)); //out("CURVE: " + mCurve + " bounds " + mCurve.getBounds2D()); //final PathIterator i = new FlatteningPathIterator(mCurve.getPathIterator(null), .001f); final PathIterator i = new FlatteningPathIterator(mCurve.getPathIterator(null), 1f); final float[] point = new float[2]; if (!i.isDone()) { // throw out first point -- kept as head.x/head.y // (the number segments often maxes out at a power of two, // meaing the total number of flattened points is often 2^x+1) i.next(); } while (!i.isDone()) { i.currentSegment(point); if (mLastPoint >= mPoints.length) { // expand mPoints to allow room for more point pairs // The current init / expand constants for mPoints are based on a // flattening path with a flatness of 1.0, where a small QuadCurve // appears to have about 17 points (34 x/y's) max, a small // CubicCurve on the order of 25 max points. float[] oldPoints = mPoints; mPoints = new float[oldPoints.length * 2]; System.arraycopy(oldPoints, 0, mPoints, 0, oldPoints.length); if (DEBUG.BOXES) out("NEW MAX SEGMENTS " + mPoints.length / 2); } mPoints[mLastPoint++] = point[0]; mPoints[mLastPoint++] = point[1]; bounds.add(point[0], point[1]); i.next(); } //mLength = 0; //for (Line2D.Float seg : new SegIterator()) mLength += lineLength(seg); // Skip computing this for now and leave mLength of the length of the straight line. // Length isn't meaingfully used with curves for the moment. if (DEBUG.BOXES) out("SEGMENTS IN FLATTENED CURVE: " + mLastPoint / 2 + "; total length estimate=" + mLength + "; maxSeg=" + mPoints.length / 2); return bounds; } /** * Compute the rotation needed to normalize the line segment to vertical orientation, making it * parrallel to the Y axis. So vertical lines will return either 0 or Math.PI (180 degrees), horizontal lines * will return +/- PI/2. (+/- 90 degrees). In the rotated space, +y values will move down, +x values will move right. */ private double computeVerticalRotation(double x1, double y1, double x2, double y2) { return VueUtil.computeVerticalRotation(x1, y1, x2, y2); // final double xdiff = x1 - x2; // final double ydiff = y1 - y2; // final double slope = xdiff / ydiff; // really, inverse slope // double radians = -Math.atan(slope); // if (xdiff >= 0 && ydiff >= 0) // radians += Math.PI; // else if (xdiff <= 0 && ydiff >= 0) // radians -= Math.PI; // // diagnostics // // if (DEBUG.BOXES) { // // if (DEBUG.LINK) out("normalizing rotation " + radians); // // if (DEBUG.META) { // this.label = // String.format("%.1f/%.1f=%.1f atan=%.3f deg=%.1f", // xdiff, ydiff, slope, radians, Math.toDegrees(radians)); // getLabelBox().setText(this.label); // // } // // } // return radians; } public void setArrowState(int arrowState) { mArrowState.set(arrowState); } public int getArrowState() { return mArrowState.get(); } public void rotateArrowState() { int newState = getArrowState() + 1; if (newState > ARROW_BOTH) newState = ARROW_NONE; setArrowState(newState); } /* public void setArrowState(int arrowState) { if (mArrowState == arrowState) return; Object old = new Integer(mArrowState); if (arrowState < 0 || arrowState > ARROW_BOTH) throw new IllegalArgumentException("arrowState < 0 || > " + ARROW_BOTH + ": " + arrowState); mArrowState = arrowState; layout(); notify(LWKey.LinkArrows, old); } public int getArrowState() { return mArrowState; } public void rotateArrowState() { int newState = mArrowState + 1; if (newState > ARROW_BOTH) newState = ARROW_NONE; setArrowState(newState); } */ private void drawArrows(DrawContext dc) { //------------------------------------------------------- // Draw arrows //------------------------------------------------------- AffineTransform savedTransform = dc.g.getTransform(); final double scale = getMapScale(); // we currently use the stroke width drawn around the arrows // to keep them reasonably sized relative to the line, but // we don't want any dash-pattern in the stroke for this if (mStrokeStyle.get() == StrokeStyle.SOLID) dc.g.setStroke(this.stroke); else dc.g.setStroke(StrokeStyle.SOLID.makeStroke(mStrokeWidth.get())); if ((mArrowState.get() & ARROW_HEAD) != 0) { dc.g.setColor(getStrokeColor()); dc.g.translate(head.x, head.y); dc.g.rotate(head.rotation); if (scale != 1) dc.g.scale(scale, scale); // Now we're operating in a coordinate space where the line is vertical. // Adjust the y value moves us up and down the line, whereas adjusting // the x value moves us horizontally off the line. Positive y values // move down the screen, negative up. // Move back to the left half the width of the arrow, so // that when drawn it will be centered on the line. dc.g.translate(-HeadShape.getWidth() / 2, 0); dc.g.fill(HeadShape); //if (getStrokeWidth() > 0) dc.g.setStroke(new BasicStroke(getStrokeWidth() / 2)); dc.g.draw(HeadShape); dc.g.setTransform(savedTransform); } if ((mArrowState.get() & ARROW_TAIL) != 0) { dc.g.setColor(getStrokeColor()); // draw the second arrow //dc.g.translate(line.getX2(), line.getY2()); dc.g.translate(tail.x, tail.y); dc.g.rotate(tail.rotation); if (scale != 1) dc.g.scale(scale, scale); dc.g.translate(-TailShape.getWidth() / 2, 0); // center shape on point dc.g.fill(TailShape); dc.g.draw(TailShape); dc.g.setTransform(savedTransform); } } @Override protected boolean validateInitialValues() { boolean wasBad = false; // VALIDATE ENDPOINTS if (Float.isNaN(head.x)) { head.x = 0; wasBad = true; Log.warn(this + "; head x bad"); } if (Float.isNaN(head.y)) { head.y = 0; wasBad = true; Log.warn(this + "; head y bad"); } if (Float.isNaN(tail.x)) { tail.x = 0; wasBad = true; Log.warn(this + "; tail x bad"); } if (Float.isNaN(tail.y)) { tail.y = 0; wasBad = true; Log.warn(this + "; tail y bad"); } // VALIDATE CONTROL POINTS // if (mCurve == mQuad) { // if (Float.isNaN(mQuad.ctrlx)) { // Log.warn(this + "; quad ctrlx bad"); // mQuad.ctrlx = 0; // wasBad = true; // } // if (Float.isNaN(mQuad.ctrly)) { // Log.warn(this + "; quad ctrly bad"); // mQuad.ctrly= 0; // wasBad = true; // } // } if (super.validateInitialValues()) { Log.warn(this + "; bad initial values (e.g., coordinates)"); wasBad = true; } // if (wasBad) { // mRecompute = true; // Log.warn(this + "; was bad, recomputing"); // } return wasBad; } private static boolean badCurve(QuadCurve2D.Float c) { if (Float.isNaN(c.x1) || Float.isNaN(c.y1) || Float.isNaN(c.x2) || Float.isNaN(c.y2) || Float.isNaN(c.ctrlx) || Float.isNaN(c.ctrly) ) return true; else return false; } private static boolean badCurve(CubicCurve2D.Float c) { if (Float.isNaN(c.x1) || Float.isNaN(c.y1) || Float.isNaN(c.x2) || Float.isNaN(c.y2) || Float.isNaN(c.ctrlx1) || Float.isNaN(c.ctrly1) || Float.isNaN(c.ctrlx2) || Float.isNaN(c.ctrly2) ) return true; else return false; } @Override protected void drawImpl(DrawContext dc) { if (mRecompute) computeLink(); if (isSelected() && dc.isInteractive()) { dc.g.setColor(COLOR_HIGHLIGHT); // todo peformance: cache these at common widths! dc.g.setStroke(new BasicStroke(stroke.getLineWidth() + 5, BasicStroke.CAP_ROUND, BasicStroke.JOIN_BEVEL)); dc.g.draw(getZeroShape()); } if (isCurrentlyPruned()) { drawPruned(dc); } else { drawLink(dc); } } private static final float PruneDotSize = 7; private static final float PruneDotRadius = PruneDotSize / 2.0f; // the below radius constants are for hit-detection (note that we add 1 for the stroke) private static final float PruneDotHitRadius = (PruneDotSize+1) / 2.0f; private static final float PruneDotHitRadiusSq = PruneDotHitRadius * PruneDotHitRadius; private static final RectangularShape PruneDot = new java.awt.geom.Ellipse2D.Float(0,0, PruneDotSize,PruneDotSize); // Note: PruneDot is the kind of object it would be handy to have one of per // rendering thread, presuming we never have to worry about some kind of crazy // higher-level multi-threaded rendering pipeline calling down into us from // different threads. We could probably get away with a single static object for // all drawing, but if a non-AWT thread ever attempted to render a map there could // be conflicts. A simple bit in the DC could allow us to check for AWT // rendering (which would obviously be subject to incorrectness, but would be good // enough w/out having to check the current thread against the EDT all the time). private void drawPruned(DrawContext dc) { dc.g.setStroke(VueConstants.STROKE_ONE); // sync to +strokeSize in PruneDotHitRadius // TODO: the prune-dots currently do NOT scale with the node's context, as links // handling scaling specially. This is messy. These are somewhat map // "controls", and in that sense they should have fixed size, but they don't // look like controls, so it looks wrong when the node is in a scaled context. // Resoloving this will require a design decision. It would probably be // easiest to have these drawn with the node, and have that own the prune // control, which could be a completely separate runtime object. // note: only one prune-dot should ever actually draw (only // one endpoint can ever be user-pruned at a time) tho we // allow drawing both for error detection. if (head.isPruning()) { drawPruneDot(dc, PruneDot, head); } if (tail.isPruning()) { drawPruneDot(dc, PruneDot, tail); } } private static final boolean STYLED_DOTS = false; private void drawPruneDot(DrawContext dc, RectangularShape dot, End end) { // note: this is not thread-safe -- convert to translations in/out to support that: dot.setFrameFromCenter(end.x, end.y, end.x+PruneDotRadius, end.y+PruneDotRadius); if (STYLED_DOTS && end.node != null) { // an interesting option -- make the dots "merge" visually into the node // todo: technically, would need to ensure this link got a repaint update // when node colors changed, tho only needed cover extreme corner case(s). dc.g.setColor(end.node.getRenderFillColor(dc)); dc.g.fill(dot); dc.g.setColor(end.node.getStrokeColor()); dc.g.draw(dot); } else { dc.g.setColor(Color.lightGray); dc.g.fill(dot); dc.g.setColor(Color.darkGray); dc.g.draw(dot); } } private void drawLink(DrawContext dc) { if (DEBUG.BOXES) drawDebugCurve(dc); // if (!isSelected()) { // double alpha = VUE.getInteractionToolsPanel().getAlpha(); // if (alpha != 1) { // // "Fade" this link. // dc.setAlpha(alpha); // } // } dc.g.setColor(getStrokeColor()); //------------------------------------------------------- // Draw arrow heads if there are any //------------------------------------------------------- if (mArrowState.get() != 0) { if (dc.zoom <= 0.125 && dc.isLODEnabled()) ; // don't draw arrows else drawArrows(dc); } //------------------------------------------------------- // Set the stroke width // // Note that since links are always drawn at the map level, we // need to compensate for the current scale by manually // modifying the drawn stroke width, as well as the text box. // ------------------------------------------------------- float strokeWidth = mStrokeWidth.get(); if (strokeWidth <= 0) strokeWidth = 0.5f; // if (dc.drawAbsoluteLinks) { // //dc.setAbsoluteStroke(stroke.getLineWidth() * getMapScale()); // g.setStroke(mStrokeStyle.get().makeStroke(strokeWidth / g.getTransform().getScaleX())); // } else { if (stroke == STROKE_ZERO) { // mStrokeWidth.get() was 0 // never draw an invisible link: draw zero strokes at small absolute scale tho float curScale = (float) dc.g.getTransform().getScaleX(); if (curScale > 1) strokeWidth /= curScale; dc.g.setStroke(mStrokeStyle.get().makeStroke(strokeWidth)); } else { dc.g.setStroke(stroke); } drawStroke(dc); if (!isNestedLink()) drawLinkDecorations(dc); if (DEBUG.CONTAINMENT) { dc.setAbsoluteStroke(0.75); dc.g.setColor(COLOR_SELECTION); dc.g.draw(getLocalPaintBounds()); } /* boolean headgroup = head instanceof LWGroup; boolean tailgroup = tail instanceof LWGroup; if ((headgroup || tailgroup) && dc.isInteractive() || DEBUG.BOXES) { float size = 8; if (dc.zoom < 1) size /= dc.zoom; RectangularShape dot = new java.awt.geom.Ellipse2D.Float(0,0, size,size); Composite composite = dc.g.getComposite(); dc.g.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.5f)); dc.g.setColor(Color.green); if (headgroup || DEBUG.BOXES) { dot.setFrameFromCenter(head.x, head.y, head.x+size/2, head.y+size/2); dc.g.fill(dot); } if (tailgroup || DEBUG.BOXES) { dot.setFrameFromCenter(tail.x, tail.y, tail.x+size/2, tail.y+size/2); if (DEBUG.BOXES) dc.g.setColor(Color.red); dc.g.fill(dot); } dc.g.setComposite(composite); } */ } private void drawStroke(DrawContext dc) { if (mCurve == null) { //------------------------------------------------------- // draw the line //------------------------------------------------------- dc.g.draw(mLine); } else { final Graphics2D g = dc.g; //------------------------------------------------------- // draw the curve //------------------------------------------------------- if (mCurve == mQuad && badCurve(mQuad)) { Log.warn("BAD QUAD CURVE " + this + "; " + Util.fmt(mQuad)); } else if (mCurve == mCubic && badCurve(mCubic)) { Log.warn("BAD CUBIC CURVE " + this + "; " + Util.fmt(mCubic)); } else { if (DEBUG.LINK) { Log.debug(this + "; drawing " + Util.tags(mCurve)); g.draw(mCurve); Log.debug(this + "; drew " + Util.tags(mCurve)); } else { g.draw(mCurve); } } if (DEBUG.BOXES) { dc.setAbsoluteStroke(0.5); g.setColor(COLOR_SELECTION); //Point2D first = new Point2D.Float(mPoints[0], mPoints[1]); //Point2D last = new Point2D.Float(mPoints[mLastPoint-2], mPoints[mLastPoint-1]); Point2D first = getHeadPoint(); Point2D last = getTailPoint(); for (Line2D seg : new SegIterator()) { g.draw(seg); g.draw(new Line2D.Float(first, seg.getP2())); g.draw(new Line2D.Float(last, seg.getP2())); } } if (dc.isInteractive() && (isSelected() || DEBUG.BOXES || DEBUG.CONTAINMENT) && !dc.isBrowsing()) { //------------------------------------------------------- // draw faint lines to control points if selected TODO: need to do this // at time we paint the selection, so these are always on top -- perhaps // have a LWComponent drawSkeleton, who's default is to just draw an // outline shape, which can replace the manual code in MapViewer, and in // the case of LWLink, can also draw the control lines. //------------------------------------------------------- g.setColor(COLOR_SELECTION); // todo: move these to DrawContext dc.setAbsoluteStroke(0.5); if (mCurveControls == 2) { Line2D ctrlLine = new Line2D.Float(mLine.getP1(), mCubic.getCtrlP1()); g.draw(ctrlLine); //float clx1 = line.x1 + mCubic.ctrlx ctrlLine.setLine(mLine.getP2(), mCubic.getCtrlP2()); g.draw(ctrlLine); } else { Line2D ctrlLine = new Line2D.Float(mLine.getP1(), mQuad.getCtrlPt()); g.draw(ctrlLine); ctrlLine.setLine(mLine.getP2(), mQuad.getCtrlPt()); g.draw(ctrlLine); } g.setStroke(stroke); } //g.drawLine((int)line.getX1(), (int)line.getY1(), (int)curve.getCtrlX(), (int)curve.getCtrlY()); //g.drawLine((int)line.getX2(), (int)line.getY2(), (int)curve.getCtrlX(), (int)curve.getCtrlY()); } } /** Split the curves into green & red halves for debugging */ private void drawDebugCurve(DrawContext dc) { final Graphics2D g = dc.g; Composite composite = g.getComposite(); g.setComposite(AlphaComposite.getInstance(AlphaComposite.SRC_OVER, 0.5f)); if (mCurveControls == 1) { QuadCurve2D left = new QuadCurve2D.Float(); QuadCurve2D right = new QuadCurve2D.Float(); mQuad.subdivide(left,right); g.setColor(Color.green); g.setStroke(new BasicStroke(mStrokeWidth.get()+4)); g.draw(left); g.setColor(Color.red); g.draw(right); } else if (mCurveControls == 2) { CubicCurve2D left = new CubicCurve2D.Float(); CubicCurve2D right = new CubicCurve2D.Float(); mCubic.subdivide(left,right); g.setColor(Color.green); g.setStroke(new BasicStroke(mStrokeWidth.get()+4)); g.draw(left); g.setColor(Color.red); g.draw(right); } g.setComposite(composite); } @Override public Color getRenderFillColor(DrawContext dc) { if (dc != null && dc.isInteractive() && isSelected()) return COLOR_HIGHLIGHT; else return super.getRenderFillColor(dc); // if (dc != null) { // if (dc.isInteractive() && isSelected()) // return COLOR_HIGHLIGHT; // else if (parent != null) // return parent.getRenderFillColor(dc); // else // return null; // } else { // // fallback case: no fill at all is the safest // return null; // } } //private static final Color ContrastFillColor = new Color(255,255,255,224); //private static final Color ContrastFillColor = new Color(255,255,255); // transparency fill is actually just distracting private void drawLinkDecorations(DrawContext dc) { //------------------------------------------------------- // Paint label if there is one //------------------------------------------------------- if (DisplayLabels && hasLabel() && getLabelBox().getParent() == null) { // todo perf minor: only get/check label box once (also done in drawLabel) // only draw if we have a label, and it's not an active edit on the map (parent == null) drawLabel(dc); } if (mIconBlock.isShowing() && dc.zoom > ICON_BLOCK_LOD_ZOOM) { // LOD //dc.g.setStroke(STROKE_HALF); //dc.g.setColor(Color.gray); //dc.g.draw(mIconBlock); //if (fillColor != null) { // dc.g.setColor(fillColor); // dc.g.fill(mIconBlock); //} mIconBlock.draw(dc); } // todo perf: don't have to compute icon block location every time /* if (!textBoxBeingEdited && mIconBlock.isShowing()) { mIconBlock.layout(); // at right //float ibx = getLabelX() + textBoxWidth; //float iby = getLabelY(); // at bottom float ibx = getCenterX() - mIconBlock.width / 2; float iby = getLabelY() + textBoxHeight; mIconBlock.setLocation(ibx, iby); mIconBlock.draw(dc); } */ } private void drawLabel(DrawContext dc) { final TextBox textBox = getLabelBox(); // We force a fill color on link labels to make sure we create // a contrast between the text and the background, which otherwise // would include the usually black link stroke in the middle, obscuring // some of the text. // todo perf: only set opaque-bit/background once/when it changes. // (probably put a textbox factory on LWComponent and override in LWLink) // if (fillColor == null || !dc.isInteractive()) { // textBox.setOpaque(false); // } else { // textBox.setBackground(fillColor); // textBox.setOpaque(true); // } if (dc.isDraftQuality()) { textBox.setBackground(null); textBox.setOpaque(false); } else { Color textFill = getRenderFillColor(dc); if (textFill != null || dc.isInteractive()) { // experiment in color mixing: if (textFill != null && textFill.getAlpha() != 255 && parent != null && parent.getParent() != null) { Color fill = parent.getParent().getRenderFillColor(dc); // really want to find the first non-transparent & non-alpha color if (fill != null && fill.getAlpha() == 255) textFill = Util.alphaMix(textFill, fill); } textBox.setBackground(textFill == null ? Color.white : textFill); textBox.setOpaque(true); //if (DEBUG.IMAGE) out("textFill: " + textFill); } else { textBox.setBackground(null); textBox.setOpaque(false); //if (DEBUG.Enabled) Util.printStackTrace(this + "; FYI: null (transparent) text fill"); } } final float lx = textBox.getBoxX(); final float ly = textBox.getBoxY(); dc.g.translate(lx, ly); textBox.draw(dc); if (DEBUG.LINK && DEBUG.META) { dc.g.setColor(Color.red); //dc.g.setFont(getFont().deriveFont(Font.BOLD, 8f)); dc.g.setFont(VueConstants.FixedSmallFont.deriveFont(Font.BOLD, 7f)); final float inc = 8; final Rectangle2D tbounds = textBox.getBoxBounds(); float y = (float) tbounds.getHeight(); //float y = textBox.getMapHeight(); dc.g.drawString(parent.getUniqueComponentTypeLabel(), 0, y += inc); dc.g.drawString(String.format("txtBounds: %s", Util.out(tbounds)), 0, y += inc); dc.g.drawString(String.format("centerZro: %+4.0f,%+4.0f", getZeroCenterX(), getZeroCenterY()), 0, y += inc); dc.g.drawString(String.format("centerMap: %+4.0f,%+4.0f", getMapCenterX(), getMapCenterY()), 0, y += inc); dc.g.drawString(String.format(" head: %+4.0f,%+4.0f", head.x, head.y), 0, y += inc); dc.g.drawString(String.format(" tail: %+4.0f,%+4.0f", tail.x, tail.y), 0, y += inc); //dc.g.drawString(parent.getDiagnosticLabel(), 0, 30); } /* draw border if (isSelected()) { Dimension s = textBox.getSize(); g.setColor(COLOR_SELECTION); //g.setStroke(STROKE_HALF); // todo: needs to be unscaled / handled by selection g.setStroke(new BasicStroke(1f / (float) dc.zoom)); // -- i guess we could compute based on zoom level -- maybe MapViewer could // keep such a stroke handy for us... (DrawContext would be handy again...) g.drawRect(0,0, s.width, s.height); } */ dc.g.translate(-lx, -ly); } private float lineLength(float x1, float y1, float x2, float y2) { final float dx = x1 - x2; final float dy = y1 - y2; return (float) Math.sqrt(dx * dx + dy * dy); } private float lineLength(Line2D.Float l) { return lineLength(l.x1, l.y1, l.x2, l.y2); } @Override protected void layoutImpl(Object triggerKey) { if (triggerKey == Flag.COLLAPSED) { if (head.node != null && head.node.isHidden(HideCause.COLLAPSED)) { setHidden(HideCause.COLLAPSED); return; } if (tail.node != null && tail.node.isHidden(HideCause.COLLAPSED)) { setHidden(HideCause.COLLAPSED); return; } clearHidden(HideCause.COLLAPSED); } final float cx; final float cy; // Note: as the label box position isn't persisted, it's useful // to be able to position it in layout based on already saved // endpoint positions during a map restore. if (mRecompute) { // added 2007-07-02 to make sure links are computed // during the map-restore layout call. // watch this for causing problems, esp w/link label editing workflow // 2007-07-21 Now we mark links as computed specially during restore, so this not a problem. computeLink(); } if (mCurveControls > 0) { cx = mCurveCenterX; cy = mCurveCenterY; } else { cx = mCenterX; cy = mCenterY; } float totalHeight = 0; float totalWidth = 0; final boolean putBelow = hasResource(); // Always call LWIcon.Block.layout first to have it compute size/determine if showing // before asking it if isShowing() TextBox textBox; boolean vertical = false; if (hasLabel() && !putBelow) { // Check to see if we want to make it vertical mIconBlock.setOrientation(LWIcon.Block.VERTICAL); mIconBlock.layout(); vertical = (getLabelBox().getBoxHeight() >= mIconBlock.getHeight()); if (!vertical) { mIconBlock.setOrientation(LWIcon.Block.HORIZONTAL); mIconBlock.layout(); } } else { // default to horizontal mIconBlock.setOrientation(LWIcon.Block.HORIZONTAL); mIconBlock.layout(); } boolean iconBlockShowing = mIconBlock.isShowing(); // must ask isShowing *after* mIconBlock.layout() if (iconBlockShowing) { totalWidth += mIconBlock.getWidth(); totalHeight += mIconBlock.getHeight(); } float lx = 0; float ly = 0; if (labelBox == null && hasLabel()) getLabelBox(); // make sure labelBox is set if we have a label if (labelBox != null) { // Record the location of the TextBox (used later for picking). The // coordinates are in the default coordinate space of the LWLink, which for // links is always the coordinate space of it's parent. totalWidth += labelBox.getBoxWidth(); totalHeight += labelBox.getBoxHeight(); if (putBelow) { // for putting icons below lx = cx - labelBox.getBoxWidth() / 2; ly = cy - totalHeight / 2; //if (iconBlockShowing) // put label just over center so link splits block & label if horizontal //ly = cy - (labelBox.getMapHeight() + getStrokeWidth() / 2); } else { // for putting icons at right lx = cx - totalWidth / 2; ly = cy - labelBox.getBoxHeight() / 2; } labelBox.setBoxLocation(lx, ly); } if (iconBlockShowing) { float ibx, iby; if (putBelow) { // for below ibx = (float) (cx - mIconBlock.getWidth() / 2); if (hasLabel()) iby = labelBox.getBoxY() + labelBox.getBoxHeight(); else iby = (float) (cy - mIconBlock.getHeight() / 2f); // we're seeing a sub-pixel gap -- this should fix iby -= 0.5; } else { // for at right if (hasLabel()) ibx = (float) lx + labelBox.getBoxWidth(); else ibx = (float) (cx - mIconBlock.getWidth() / 2); iby = (float) (cy - mIconBlock.getHeight() / 2); // we're also seeing a sub-pixel gap here -- this should fix ibx -= 0.5; } mIconBlock.setLocation(ibx, iby); } } @Override public void initTextBoxLocation(TextBox textBox) { if (mRecompute) computeLink(); out("setboxcenter " + Util.fmt(new Point2D.Float(getZeroCenterX(), getZeroCenterY()))); textBox.setBoxCenter(getZeroCenterX(), getZeroCenterY()); // if (mCurveControls > 0) // textBox.setBoxCenter(mCurveCenterX, mCurveCenterY); // else // textBox.setBoxCenter((head.x + tail.x) / 2, // (head.y + tail.y) / 2); } @Override protected float getZeroCenterX() { //if (mRecompute) computeLink(); // risks recursion loop (stack overflow) if we have a link-loop return mCurveControls > 0 ? mCurveCenterX : mCenterX; } @Override protected float getZeroCenterY() { //if (mRecompute) computeLink(); // risks recursion loop (stack overflow) if we have a link-loop return mCurveControls > 0 ? mCurveCenterY : mCenterY; } @Override public float getMapCenterX() { return parent.getMapX() + getZeroCenterX() * parent.getMapScaleF(); } @Override public float getMapCenterY() { return parent.getMapX() + getZeroCenterY() * parent.getMapScaleF(); } // @Override // public float getLinkConnectionX(LWContainer ancestor) { // if (mCurveControls > 0) { // if (ancestor == this.parent) // return mCurveCenterX; // else // return (float) (parent.getMapX() + parent.getMapScale() * mCurveCenterX); // } else // return getCenterX(ancestor); // } // @Override // public float getLinkConnectionY(LWContainer ancestor) { // if (mCurveControls > 0) { // if (ancestor == this.parent) // return mCurveCenterY; // else // return (float) (parent.getMapY() + parent.getMapScale() * mCurveCenterY); // } else // return getCenterY(ancestor); // } // @Override // public float getCenterX() { // return getCenterX(getMap()); // todo: slow // // if (LOCAL_LINKS) // // return getCenterX(getMap()); // todo: slow // // else // // return mCurveControls > 0 ? mCurveCenterX : (head.x + tail.x) / 2; // } // @Override // public float getCenterY() { // return getCenterY(getMap()); // todo: slow // // if (LOCAL_LINKS) // // return getCenterY(getMap()); // todo: slow // // else // // return mCurveControls > 0 ? mCurveCenterY : (head.y + tail.y) / 2; // } // We override these, which is what the links themseleves use to find the centerpoint // of what they connect to, so that if one our our endpoints is another link, // and it's curved, we'll connect to the curve center (where the label goes). // @Override // public float getCenterX(LWContainer ancestor) { // //if (ancestor != parent) Util.printStackTrace("ancestor != parent: " + ancestor + "; " + parent); // return mCurveControls > 0 ? mCurveCenterX : (head.x + tail.x) / 2; // } // @Override // public float getCenterY(LWContainer ancestor) { // //if (ancestor != parent) Util.printStackTrace("ancestor != parent: " + ancestor + "; " + parent); // return mCurveControls > 0 ? mCurveCenterY : (head.y + tail.y) / 2; // } /** Create a duplicate LWLink. The new link will * not be connected to any endpoints */ @Override public LWLink duplicate(CopyContext cc) { //todo: make sure we've got everything (styles, etc) final LWLink link = (LWLink) super.duplicate(cc); link.head.duplicate(head); link.tail.duplicate(tail); link.mCenterX = mCenterX; link.mCenterY = mCenterY; //link.ordered = ordered; //link.mArrowState = mArrowState; if (mCurveControls > 0) { link.setCtrlPoint0(getCtrlPoint0()); if (mCurveControls > 1) link.setCtrlPoint1(getCtrlPoint1()); } //computeLink(); //layout(); // should already have been done in computeLink... return link; } @Override public String paramString() { String s = String.format("%s %.0f,%.0f-->%.0f,%.0f", mStrokeStyle.get(), head.x, head.y, tail.x, tail.y); if (getControlCount() == 1) s += String.format(" (%.0f,%.0f)", mQuad.ctrlx, mQuad.ctrly); else if (getControlCount() == 2) s += String.format(" (%.0f,%.0f & %.0f,%.0f)", mCubic.ctrlx1, mCubic.ctrly1, mCubic.ctrlx2, mCubic.ctrly2); if (head.pruned) s += " X-HEAD"; if (tail.pruned) s += " X-TAIL"; return s; } /** @deprecated -- use getHead */ public LWComponent getComponent1() { return getHead(); } /** @deprecated -- use getTail */ public LWComponent getComponent2() { return getTail(); } /** @deprecated -- use setHeadPoint */ public void setStartPoint(float x, float y) { setHeadPoint(x, y); } /** @deprecated -- use setTailPoint */ public void setEndPoint(float x, float y) { setTailPoint(x, y); } /** @deprecated -- use getHeadPoint */ public Point2D getPoint1() { return getHeadPoint(); } /** @deprecated -- use getTailPoint */ public Point2D getPoint2() { return getTailPoint(); } /** @deprecated -- no longer needed (now using castor references), always returns null */ public String getHead_ID() { return null; } /** @deprecated -- no longer needed (now using castor references), always returns null */ public String getTail_ID() { return null; } /** for persistance/init/undo ONLY */ public void setHeadPoint(Point2D p) { if (mXMLRestoreUnderway) { head.x = (float) p.getX(); head.y = (float) p.getY(); } else { setHeadPoint((float)p.getX(), (float)p.getY()); } } /** for persistance/init/undo ONLY */ public void setTailPoint(Point2D p) { if (mXMLRestoreUnderway) { tail.x = (float) p.getX(); tail.y = (float) p.getY(); } else { setTailPoint((float)p.getX(), (float)p.getY()); } } /** for persistance/init ONLY */ public Point2D.Float getHeadPoint() { return new Point2D.Float(head.x, head.y); } /** for persistance/init ONLY */ public Point2D.Float getTailPoint() { return new Point2D.Float(tail.x, tail.y); } // these two to support a special dynamic link // which we use while creating a new link //boolean viewerCreationLink = false; // todo: this boolean a hack until we no longer need to use // clip-regions to draw the links LWLink(LWComponent tailNode) { initLink(); //viewerCreationLink = true; tail.node = tailNode; setStrokeWidth(2f); //todo config: default link width } // sets head WIHOUT adding a link ref -- used for // temporary drawing of link hack during drag outs -- // you know, we should just skip using a LWLink object // for that crap alltogether. TODO void setTemporaryEndPoint1(LWComponent headNode) { head.node = headNode; } }