Today I implemented the ability to select graphic elements using a mouse click. This involves sensing the mouse click event and its location, variations of which we’ve already visited, and identifying the graphic element on that part of the screen. For the first pass the code only allows the user to select or deselect one or more items, and the only effect is to change an item’s color scheme. The standard neutral, ready, and waiting colors for components and paths are yellow (#FFFF22), green (#22FF22), and red (#FF2222), and selecting an element simply changes the blue values from 22 to FF (e.g., #FFFFFF, #22FFFF, #FF22FF). Deselecting an item, which is only selecting an item that has already been selected, returns the item to its original state and colors.

The first thing we have to do is sense the (left) mouse click and the location:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
///////////////////mouse click events var componentSelectionIndex; //click event canvas.addEventListener("click", function(event) { on2dClick(event); }); function on2dClick(e) { if (e.button == 0) { //left button = 0, middle = 1, right = 2 var clickX = e.clientX; var clickY = e.clientY; componentSelectionIndex = selectComponent(clickX, clickY); } } |

Now for the actual scan of components. This code ignores components that do not have graphic representations and stops searching when it finds the first match. In cases where a selection could plausibly select more than one item it assumes that the user will change the display order of the items to make a different item come first in the component list. Visio and other drawing programs do this with the “Send to Back” and “Bring to Front” operations, neither of which have been implemented in this code (yet).

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
function selectComponent(x, y) { var i = 0; if (setOfComponents.length > 0) { var done = false; while (!done) { if ((typeof setOfComponents[i].graphic !== "undefined") && (setOfComponents[i].graphic.isClickInside(x, y))) { done = true; } else { i++; } if (i >= setOfComponents.length) { done = true; } } if (i < setOfComponents.length) { if (!setOfComponents[i].graphic.highlighted) { setOfComponents[i].graphic.highlight(); } else { setOfComponents[i].graphic.unHighlight(); } return i; } else { return -1; } } else { return -1; } } |

The key is obviously how the components’ graphic representations perform the required hit testing:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 |
//*************************************************************************************** //*** variables for hit testing //*************************************************************************************** var clickDistanceBand = 5; //*************************************************************************************** //*** graphics display variables //*************************************************************************************** function DisplayElement(parent,x1,y1,w,h,angle,count,traverse,xylocs) { ... this.isClickInside = function(clickX, clickY) { if (!this.isJourney) { if (this.isPath && (Math.abs(this.x1 - this.x2) < 5.0)) { if ((clickY >= this.y1 && clickY <= this.y2) || (clickY >= this.y2 && clickY <= this.y1)) { if (Math.abs(clickX - (this.x1 + this.x2) * 0.5) > clickDistanceBand) { return false; } else { return true; } } } else if (this.isPath && (Math.abs(this.y1 - this.y2) < 5.0)) { if ((clickX >= this.x1 && clickX <= this.x2) || (clickX >= this.x2 && clickY <= this.x1)) { if (Math.abs(clickY - (this.y1 + this.y2) * 0.5) > clickDistanceBand) { return false; } else { return true; } } } else if (((clickX >= this.x1 && clickX <= this.x2) || (clickX >= this.x2 && clickX <= this.x1)) && ((clickY >= this.y1 && clickY <= this.y2) || (clickY >= this.y2 && clickY <= this.y1))) { if (this.isPath) { var slope1 = (this.y2-this.y1)/(this.x2-this.x1); var yintercept1 = this.y1 - slope1 * this.x1; var slope2 = -1.0 / slope1; var yintercept2 = clickY - slope2 * clickX; //find intersection point var crossX = (yintercept2 - yintercept1) / (slope1 - slope2); var crossY = slope1 * crossX + yintercept1; //find distance between click and crossing point var distance = Math.sqrt((clickX - crossX) * (clickX - crossX) + (clickY - crossY) * (clickY - crossY)); if (distance > clickDistanceBand) { return false; } } return true; } } else { if (this.innode) { if (clickX >= this.x1-5 && clickX <= this.x1+4 && clickY >= this.y1-5 && clickY <= this.y1+4) { return true; } } else if (this.outnode) { if (clickX >= this.x2-5 && clickX <= this.x2+4 && clickY >= this.y2-5 && clickY <= this.y2+4) { return true; } } } return false; }; ... } //DisplayElement |

There’s a *lot* to say about this code. The main test is the two lines near the middle and everything else is a special case of sorts.

1 2 |
} else if (((clickX >= this.x1 && clickX <= this.x2) || (clickX >= this.x2 && clickX <= this.x1)) && ((clickY >= this.y1 && clickY <= this.y2) || (clickY >= this.y2 && clickY <= this.y1))) { |

Note that this checks against rectangles defined both up and down and left and right, and not just down and to the right. The simpler test might work for most rectangular area components but would fail for Path components that run in directions other than down and right. This test performs a naive check to see if the click falls within a rectangular area (that *has not* been rotated). If the component is not a Path then the test is true and the work is done. If the component is a Path then the process is a little more complicated. The code first figures out the point-slope formula for the path in the form:

y = mx + b

where:

y = y coordinate, dependent variable

m = slope of line, (y_{2} – y_{1}) / (x_{2} – x_{1})

x = x coordinate, independent variable

b = y-intercept of line (y value when x = 0)

The code then constructs another line perpendicular to the first line, which has a slope of -1/m. Here it’s important to ensure that we don’t run this code on paths that are vertical, or have infinite slope, and you can see that lines that are close to being horizontal or vertical are handled separately.

I then substitute the second equation into the first, eliminating the y and ending up with

m_{1}x_{1} + b_{1} = m_{2}x_{2} + b_{2}

m_{1}x_{1} – m_{2}x_{2} = b_{2} – b_{1}

x(m_{1} – m_{2}) = b_{2} – b_{1}

and

x = (b_{2} – b_{1}) / (m_{1} – m_{2})

Once we have the common x-coordinate we solve for the common y, and then we can use the Pythagorean Theorem to determine the shortest straight line distance from the click location to the line of interest. If the click point isn’t close enough (the setting is currently 5 pixels) then the routine returns false and the search through the component list continues.

If lines are nearly horizontal or vertical the code simplifies things a bit. It basically tests to see whether the click point is within five pixels of the midpoint of a horizontal or vertical line that is close to perpendicular to the Path or interest. This is not limited to purely vertical or horizontal lines in order to ensure that the hit testing area is a bit larger than it might be if we were only to test within the rectangle defined by the endpoints. A purely horizontal or vertical line would yield a test rectangle of zero width and lines that are nearly horizontal or vertical would be almost as bad. Who wants to limit the user in such a way?

Finally, the innode and outnode subcomponents of Bags are handled down at the bottom.

A few improvements are needed here. The testing for the size of the innode and outnode subcomponents of Bags needs to be parameterized. The test for rectangular components needs to be able to test for elements that have been rotated. I’ve written code for this previously and there are a couple of ways to do it. The entire process needs to be modified to handle different levels of zooming and magnification. I’ll be working through all of these processes over time.

The last bit of code has to do with highlighting and unhighlighting the components’ graphic representations, which is pretty straigthtforward:

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 |
function DisplayElement(parent,x1,y1,w,h,angle,count,traverse,xylocs) { ... this.defineColors = function(lc,vc,nc,nvc,rc,rvc,wc,wvc) { this.labelColor = lc; //text labels this.valueColor = vc; //text values this.neutralColor = nc; //non-exclusive path and component color this.neutralVertexColor = nvc; //non-exclusive wireframe color this.readyColor = rc; //exclusive open component or ready-to-go entity color this.readyVertexColor = rvc; //exclusive open component or ready-to-go entity vertex color this.waitingColor = wc; //exclusive closed component or in-process entity color this.waitingVertexColor = wvc; //exclusive closed component or in-process entity vertex color this.oldNeutralColor = this.neutralColor; this.oldReadyColor = this.readyColor; //bottom three lines are new this.oldWaitingColor = this.waitingColor; }; ... this.highlight = function() { this.oldNeutralColor = this.neutralColor; this.oldReadyColor = this.readyColor; this.oldWaitingColor = this.waitingColor; this.neutralColor = "#FFFFFF"; this.readyColor = "#22FFFF"; this.waitingColor = "#FF22FF"; this.highlighted = true; this.drawBasic(); }; this.unHighlight = function() { this.neutralColor = this.oldNeutralColor; this.readyColor = this.oldReadyColor; this.waitingColor = this.oldWaitingColor; this.highlighted = false; this.drawBasic(); }; ... } //DisplayElement |

The value for `this.highlighted`

is initialized to false above this. This can all be more generalized.

Once an item is selected we can start to interact with it in various ways. We’ll do that and more going forward. In the meantime, feel free to click on the different components and see what it takes to get each of them highlighted and unhighlighted. This all works whether the simulation is running or not, for now. I chose a fairly simple set of color changes and something with a higher contrast would probably be better, but what I’ve done serves the purposes for demonstration.

I have not yet implemented a way to select moving entities, but that process would be even simpler than selecting components, at least for circular ones. More complicated entity shapes and orientations are obviously possible.