package votorola.s.gwt.stage.vote; // Copyright 2012-2013, Michael Allan. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Votorola Software"), to deal in the Votorola Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicence, and/or sell copies of the Votorola Software, and to permit persons to whom the Votorola Software is furnished to do so, subject to the following conditions: The preceding copyright notice and this permission notice shall be included in all copies or substantial portions of the Votorola Software. THE VOTOROLA SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE VOTOROLA SOFTWARE OR THE USE OR OTHER DEALINGS IN THE VOTOROLA SOFTWARE. import com.google.gwt.event.dom.client.ClickEvent; import com.google.gwt.event.dom.client.ClickHandler; import com.google.gwt.event.dom.client.MouseOutEvent; import com.google.gwt.event.dom.client.MouseOutHandler; import com.google.gwt.event.dom.client.MouseOverEvent; import com.google.gwt.event.dom.client.MouseOverHandler; import com.google.gwt.event.shared.GwtEvent; import com.google.web.bindery.event.shared.HandlerRegistration; import org.vectomatic.dom.svg.*; import votorola.a.count.*; import votorola.a.count.gwt.*; import votorola.a.web.gwt.*; import votorola.g.hold.*; import votorola.g.lang.*; import votorola.g.web.gwt.*; import votorola.g.web.gwt.event.*; import votorola.g.web.gwt.svg.*; import votorola.s.gwt.stage.*; import votorola.s.gwt.stage.light.*; import static com.google.gwt.dom.client.Style.Display.INLINE; import static votorola.a.count.XCastRelation.VOTER; import static votorola.a.count.XCastRelation.CO_VOTER; import static votorola.a.count.XCastRelation.CANDIDATE; /** A view of a count node in the shape of an arrow segment. Clicking on the view sets or * unsets the node as the {@linkplain Stage#getActorName() stage actor}.
  *
  *                       protrusion
  *                         /
  *       |---- length ---|--|
  *
  *    p0 +---------------+    -
  *        \               \   | half thickness
  *         +       *       +  -
  *        /               /
  *       +---------------+
* * If the node is a cycler, then a spot (*) is visible in the middle of the view. * * @see Acknowledgement to Christian Weilbach */ public class NodeV extends OMSVGGElement { /** Constructs a NodeV. * * @see #box() * @see #dartSector() */ NodeV( Box _box, int _dartSector, final VoteTrack track ) { // track needed only because box.trackV is null during init box = _box; dartSector = _dartSector; // View. // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - addClassNameBaseVal( "CountNode" ); final XCastRelation place = box.place(); String s = Character.toString( place.symbol() ); if( place == XCastRelation.VOTER || place == XCastRelation.CO_VOTER ) s += dartSector; dartLightClassName = s; addClassNameBaseVal( dartLightClassName ); appendChild( new ArrowSegment() ); appendChild( new CycleSpot() ); // Controllers. // - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - new Clicker(); highlighter = new Highlighter(); new Mouser(); sensor = new Sensor(); tracker = new Tracker( track ); } /** Forces static initialization of this class and appends a post-configuration * initializer to the specified spool. * * @param configurationSpool a spool that will unwind immediately after * {@linkplain StageMod module configuration} is complete. */ public static void forceInitClass( final Spool configurationSpool ) { configurationSpool.add( new Hold() { public void release() { if( deselectionGuard == null ) deselectionGuard = new DefaultDeselect(); } }); } // ------------------------------------------------------------------------------------ /** The ancestral container of this view, the {@linkplain MajorV#spool() spool} of * which controls its life cycle. */ public final Box box() { return box; } private final Box box; /** The CSS class name for the {@linkplain DartLight dart light}, such as "v17". */ final String dartLightClassName() { return dartLightClassName; } private final String dartLightClassName; /** The dart sector to which this view is restricted, or zero if it is not restricted * to any particular sector. * * @see votorola.a.count.CountNode#dartSector() */ final int dartSector() { return dartSector; } private final int dartSector; /** The count node on which this view is modelled, or null if none is modelled. * * @see #setCountNode(CountNodeJS) */ public final CountNodeJS getCountNode() { return countNode; } // bearing in mind this is a subclass of OMNode private CountNodeJS countNode; /** Sets the count node on which this view is modelled. * * @return true if setting the node resulted in a change, false if that node * was already set. * @see #getCountNode() * @throws IllegalArgumentException if the node does not have the same dart * sector as this view. */ final boolean setCountNode( final CountNodeJS node ) { if( ObjectX.nullEquals( node, countNode )) return false; // Guard against pointless repaint, though it is not currently expensive. // This is valid only because the node is immutable except its 'voters' // array, which has no bearing on the view. final VoteTrack track = box.trackV().track(); if( node == null ) { if( countNode != null ) removeClassNameBaseVal( "occupied" ); // it changed clearCycler(); sensor.clear(); } else { if( dartSector != 0 && node.dartSector() != dartSector ) { throw new IllegalArgumentException( "dart sector mismatch" ); } if( countNode == null ) addClassNameBaseVal( "occupied" ); // it changed if( node.isCycler() ) setCycler(); else clearCycler(); sensor.set( track.count(), node ); } countNode = node; tracker.run( track ); highlighter.relight(); return true; } /** Answers whether the node is in a tight cycle with the anchor. * * @see tight cycle */ final boolean isTightCycler() { return isTightCycler; } private boolean isTightCycler; /** The horizontal length of the top line of the drawing as currently drawn in the SVG * viewport. */ public final float length() { final OMSVGPathSegLinetoRel seg = (OMSVGPathSegLinetoRel) arrowSegment().getPathSegList().getItem( 1 ); return seg.getX(); } /** The point at the top left of the drawing as currently drawn in the SVG viewport. */ public final OMSVGPathSegMovetoAbs p0() { return (OMSVGPathSegMovetoAbs)arrowSegment().getPathSegList().getItem( 0 ); } /** The horizontal extent of the arrowhead beyond the {@linkplain #length() length}, * as currently drawn in the SVG viewport. */ public final float protrusion() { final OMSVGPathSegLinetoRel seg = (OMSVGPathSegLinetoRel) arrowSegment().getPathSegList().getItem( 2 ); return seg.getX(); } /** Redraws this view according to the provided parameters. The view never * automatically initiates a repaint, but depends entirely on its MajorV ancestor for * this. */ void repaint( final float x, float length, final float protrusion, final int y, final float halfThickness ) { arrowSegment().repaint( x, length, protrusion, y, halfThickness ); cycleSpot().repaint( x, length, protrusion, y, halfThickness ); } /** Sets the type of {@linkplain DeselectionGuard deselection guard}. Call this * method from the global configuration function {@linkplain StageMod * voGWTConfig.s_gwt_stage} in this fashion:
      *
      *   s_gwt_stage_vote_CountNodeV_setDeselectionGuard( 'Blind' );
      *     // default is 'Default'
* * Or call it at any time during normal operation. * * @throws IllegalArgumentException if the provided guard name is unrecognized. */ public static @GWTConfigCallback void setDeselectionGuard( final String guardName ) { final DeselectionGuard g; if( BlindDeselect.NAME.equals( guardName )) g = new BlindDeselect(); else if( DefaultDeselect.NAME.equals( guardName )) g = new DefaultDeselect(); else if( LaxDeselect.NAME.equals( guardName )) g = new LaxDeselect(); else throw new IllegalArgumentException( "no such guard: " + guardName ); deselectionGuard = g; GWTX.i().bus().fireEventFromSource( new PropertyChange("deselectionGuard"), NodeV.class ); } /** The value is bound via the {@linkplain GWTX#bus() event bus} to property name * deselectionGuard on source NodeV.class. */ private static DeselectionGuard deselectionGuard; private static native void exposeDeselectionGuard() /*-{ $wnd.s_gwt_stage_vote_CountNodeV_setDeselectionGuard = $entry( @votorola.s.gwt.stage.vote.NodeV::setDeselectionGuard(Ljava/lang/String;) ); }-*/; static { assert StageMod.isForcedInit(): "forced init " + NodeV.class.getName(); exposeDeselectionGuard(); } /** The stroke width for painting the main figure (arrow segment). */ public static final int STROKE_WIDTH = 2; // from stage/vote/track.css // - O M - N o d e ------------------------------------------------------------------- public @Override final void fireEvent( final GwtEvent e ) { try{ super.fireEvent( e ); } catch( Exception x ) { GWTX.handleUncaughtException( x ); } // q.v. for reason } public @Override final NodeV getNextSibling() { return (NodeV)super.getNextSibling(); } // auto and explicit {@inheritDoc} fail, JDK 1.7 public @Override final NodeV getPreviousSibling() // ditto {@inheritDoc} failure { return (NodeV)super.getPreviousSibling(); } // ==================================================================================== // /** The {@value NAME} deselection guard. It allows for deselection of nodes that are // * base candidates or non-participants (both being tracked at the base), provided no // * {@linkplain Stage#getDefaultActorName() default actor} is set. This allows the // * track to fall cleanly into its default state where only the base peer board is // * displayed. This will not normally happen when a default actor is set, of course, // * and that is why deselection is disabled in that case. // */ // static final class BaseCandidateDeselect implements DeselectionGuard // { // // public void guard( final NodeV nodeV ) // { // final CountNodeJS node = nodeV.countNode; // nodeV.isClickable = Stage.i().getDefaultActorName() == null && ( node.isBaseCandidate() // ||/*non-participant*/ !node.isVoter() && !node.isCandidate() ); // } // // // static final String NAME = "BaseCandidate"; // // } ///// But the track is pinned when a default actor is set, so its unclear why deselection ///// should be forbidden in that case. Being pinned, the track won't snap back far. // ==================================================================================== // /** The {@value NAME} deselection guard. It is the same as {@linkplain // * BaseCandidateDeselect BaseCandidateDeselect} except it always allows for the // * deselection of mosquitos. This is useful because there is ordinarily no way to // * navigate away from a mosquito once it is selected except by deselecting it. // */ // static final class BaseMosquitoDeselect implements DeselectionGuard // { // // public void guard( final NodeV nodeV ) // { // final CountNodeJS node = nodeV.countNode; // nodeV.isClickable = node.dartSector() == 0 // || Stage.i().getDefaultActorName() == null && node.isBaseCandidate(); // } // // // static final String NAME = "BaseMosquito"; // // } ///// But a mosquito may be deselected by selecting another node, so the use case is ///// unclear. Maybe the intention was to deselect a non-participant node, because ///// otherwise it may get stuck. // ==================================================================================== /** The {@value NAME} deselection guard. It allows for deselection of any node. If a * {@linkplain Stage#getDefaultActorName() default actor} is set, then it cannot * actually be deselected. But this guard will not prevent the attempt. */ static final class BlindDeselect implements DeselectionGuard { public void guard( final NodeV nodeV ) { nodeV.isClickable = true; } static final String NAME = "Blind"; } // ==================================================================================== /** A container of node views. */ public static abstract class Box extends MajorV { /** Creates a new Box. * * @see MajorV#place() * @see MajorV#trackV() */ Box( XCastRelation _place, VoteTrackV _trackV ) { super( _place, _trackV ); } // ------------------------------------------------------------------------------------ /** The nodal components of this view. */ public abstract Iterable nodeViews(); /** The source of {@linkplain Change change events} that are fired on the * {@linkplain GWTX#bus() bus} after each repainting of the node views. */ public abstract Object painter(); } // ==================================================================================== /** The {@value NAME} deselection guard. It disallows deselection of a {@linkplain * Stage#getDefaultActorName() default actor} node, because such a node cannot really * be deselected in any case. If the track is {@linkplain VoteTrack#toPin(Stage) * unpinned}, then it also disallows the deselection of all but base candidate and * non-participant nodes. Deselection will cause the unpinned track to drop to its * default state in which only the base peer board is displayed. The user may easily * reselect a base candidate or non-participant in that state, while other nodes * might require a climb back up the tree. */ static final class DefaultDeselect implements DeselectionGuard { public void guard( final NodeV nodeV ) { final Stage stage = Stage.i(); final CountNodeJS node = nodeV.countNode; if( VoteTrack.toPin( stage )) { nodeV.isClickable = !node.name().equals( stage.getDefaultActorName() ); } else // unpinned track { assert stage.getDefaultActorName() == null; // node cannot be default actor nodeV.isClickable = node.isBaseCandidate() ||/*non-participant*/ !node.isVoter() && !node.isCandidate(); } } static final String NAME = "Default"; } // ==================================================================================== /** A controller that determines whether a selected node view may be deselected. */ interface DeselectionGuard { /** Sets nodeV.{@linkplain NodeV#isClickable isClickable} according to whether * nodeV may be deselected. nodeV must be modelled by an actual node. */ public void guard( final NodeV nodeV ); } // ==================================================================================== /** The {@value NAME} deselection guard. It allows for deselection of any node except * that of the {@linkplain Stage#getDefaultActorName() default actor}, which cannot * actually be deselected in any case. */ static final class LaxDeselect implements DeselectionGuard { public void guard( final NodeV nodeV ) { nodeV.isClickable = !nodeV.countNode.name().equals( Stage.i().getDefaultActorName() ); } static final String NAME = "Lax"; } // ==================================================================================== /** A lighting sensor for a node view. */ final class Sensor extends Sensor1 implements NodalSensor { private Sensor() { Stage.i().lightBank().addSensor( Sensor.this ); box.spool().add( new Hold() { public void release() { Stage.i().lightBank().removeSensor( Sensor.this ); } }); } private void clear() { if( pollName == null ) { assert personName == null; return; // already clear } displayTitle = null; personName = null; pollName = null; changed(); } private void set( final CountJS count, final CountNodeJS node ) { if( count == null || node == null ) { clear(); return; } displayTitle = node.displayTitle(); personName = node.name(); pollName = count.pollName(); changed(); } /** The node view associated with this lighting sensor. */ NodeV view() { return NodeV.this; } // - N o d a l - S e n s o r ------------------------------------------------------ public NodeV.Box box() { return box; } public String displayTitle() { return displayTitle; } private String displayTitle; // - P o s i t i o n - S e n s o r ------------------------------------------------ public String personName() { return personName; } private String personName; public String pollName() { return pollName; } private String pollName; } //// P r i v a t e /////////////////////////////////////////////////////////////////////// private ArrowSegment arrowSegment() { return (ArrowSegment)getFirstChild(); } private CycleSpot cycleSpot() { return (CycleSpot)getLastChild(); } private final Highlighter highlighter; private boolean isClickable; /** Answers whether the node is a {@linkplain CountNode#isCycler cycler}, returning * false if the node is null. */ private boolean isCycler() { return countNode == null? false: countNode.isCycler(); } private void setCycler() { cycleSpot().getStyle().setDisplay( INLINE ); } private void clearCycler() { cycleSpot().getStyle().clearDisplay(); } // default to "none" as set in vote/track.css private final Sensor sensor; private final Tracker tracker; // ==================================================================================== private final class ArrowSegment extends OMSVGPathElement { ArrowSegment() { addClassNameBaseVal( "ArrowSegment" ); final OMSVGPathSegList sL = getPathSegList(); /*0*/sL.appendItem( createSVGPathSegMovetoAbs( 0,0 )); /*1*/sL.appendItem( createSVGPathSegLinetoRel( 0,0 )); /*2*/sL.appendItem( createSVGPathSegLinetoRel( 0,0 )); /*3*/sL.appendItem( createSVGPathSegLinetoRel( 0,0 )); /*4*/sL.appendItem( createSVGPathSegLinetoRel( 0,0 )); /*5*/sL.appendItem( createSVGPathSegLinetoRel( 0,0 )); /*0*/sL.appendItem( createSVGPathSegClosePath() ); } void repaint( final float x, final float length, final float protrusion, final int y, final float halfThickness ) { final OMSVGPathSegList sL = getPathSegList(); // 0 1 // +--------------+ // \ \ // + 5 + 2 // / / // +--------------+ // 4 3 PathSeg.movetoAbs( sL, 0, x, y ); PathSeg.lineToRel( sL, 1, length, 0 ); PathSeg.lineToRel( sL, 2, protrusion, halfThickness ); PathSeg.lineToRel( sL, 3, -protrusion, halfThickness ); PathSeg.lineToRel( sL, 4, -length, 0 ); PathSeg.lineToRel( sL, 5, protrusion, -halfThickness ); } } // ==================================================================================== private final class Clicker implements ClickHandler { Clicker() { addClickHandler( Clicker.this ); } // no need to unregister, registry does not outlive the handler public void onClick( ClickEvent _e ) { if( !isClickable ) return; String name = countNode.name(); if( name.equals( Stage.i().getActorName() )) name = null; // toggle off Stage.setActorName( name ); } } // ==================================================================================== private final class CycleSpot extends OMSVGCircleElement { CycleSpot() { addClassNameBaseVal( "CycleSpot" ); } private static final float MAX_RADIUS = 2f; void repaint( final float x, final float length, final float protrusion, final int y, final float halfThickness ) { if( !isCycler() ) { assert !INLINE.getCssName().equals( getStyle().getDisplay() ); return; // not displayed } final float spareWidth = length - MAX_RADIUS - STROKE_WIDTH; final float r = spareWidth > 6? MAX_RADIUS: MAX_RADIUS/2; // less when very short getR().getBaseVal().setValue( r ); getCx().getBaseVal().setValue( x + length/2 + protrusion - /*correction for perfect centering*/r ); getCy().getBaseVal().setValue( y + halfThickness ); } } // ==================================================================================== private final class Highlighter implements PropertyChangeHandler { Highlighter() { box.spool().add( new Hold() { final HandlerRegistration hR = GWTX.i().bus().addHandlerToSource( PropertyChange.TYPE, /*source*/NodeV.class, Highlighter.this ); public void release() { hR.removeHandler(); } }); box.spool().add( new Hold() { final HandlerRegistration hR = GWTX.i().bus().addHandlerToSource( PropertyChange.TYPE, /*source*/Stage.i(), Highlighter.this ); public void release() { hR.removeHandler(); } }); // relight(); // init /// no need to init, is called when node set } public final void onPropertyChange( final PropertyChange e ) { if( box.spool().isUnwinding() ) return; final String name = e.propertyName(); final Object source = e.getSource(); if( source.equals( NodeV.class )) { if( name.equals( "deselectionGuard" )) relight(); } else { assert source.equals( Stage.i() ); if( name.equals( "actorName" )) relight(); } } final @Warning("init call") void relight() { isClickable = countNode != null; // generally if( isClickable && countNode.name().equals( Stage.i().getActorName() )) { addClassNameBaseVal( "highlit" ); // if not already added deselectionGuard.guard( NodeV.this ); } else removeClassNameBaseVal( "highlit" ); if( isClickable ) addClassNameBaseVal( "clickable" ); else removeClassNameBaseVal( "clickable" ); } } // ==================================================================================== private final class Mouser implements MouseOutHandler, MouseOverHandler { Mouser() { addMouseOutHandler( Mouser.this ); // no need to unregister, registry does not outlive the handler addMouseOverHandler( Mouser.this ); // " // Might instead add one handler to the 'svg' ancestor and catch the events // as they bubble. That requires svg's pointer-events='none' and this // element's pointer-events='visible'. The NodeV can then be resolved // via event.getNativeEvent().getEventTarget()._get("__wrapper"). But then // both event types (out and over) sporadically fail on Chrome (18). } public void onMouseOut( MouseOutEvent _e ) { sensor.out(); } public void onMouseOver( MouseOverEvent _e ) { sensor.over(); } } // ==================================================================================== private final class Tracker implements ChangeHandler { Tracker( final VoteTrack track ) { box.spool().add( new Hold() { final HandlerRegistration hR = GWTX.i().bus().addHandlerToSource( Change.TYPE, /*source*/track, Tracker.this ); public void release() { hR.removeHandler(); } }); // run( track ); // init /// no need to init, is called when node set } private void clearAnchor() { if( !isAnchor ) return; removeClassNameBaseVal( "anchor" ); isAnchor = false; } private boolean isAnchor; private void setAnchor() { if( isAnchor ) return; addClassNameBaseVal( "anchor" ); isAnchor = true; } private void clearTightCycler() { if( !isTightCycler ) return; removeClassNameBaseVal( "y" ); isTightCycler = false; } private void setTightCycler() { if( isTightCycler ) return; addClassNameBaseVal( "y" ); isTightCycler = true; } public void onChange( final Change e ) { run( (VoteTrack)e.getSource() ); } final @Warning("init call") void run( final VoteTrack track ) { final CountNodeJS anchor = track.anchor(); if( anchor == null ) { clearAnchor(); clearTightCycler(); return; } final XCastRelation place = box.place(); if( place == CO_VOTER ) { if( countNode != null && countNode.dartSector() == anchor.dartSector() ) setAnchor(); else clearAnchor(); clearTightCycler(); } else { clearAnchor(); if( isCycler() ) { if( place == VOTER && countNode.name().equals(anchor.candidateName()) || place == CANDIDATE && anchor.name().equals(countNode.candidateName()) ) { assert anchor.isVoter(); assert countNode.isVoter(); /* both must cast for tight cycle, but this follows from isCycler and nominal checks above */ setTightCycler(); return; } } clearTightCycler(); } } } }