Browse Source

Network detect clicks on labels (#3410)

* Small refactoring in Label._drawBackground()

* Added size calculation to Label, basic framework for detecting click on label.

* First fully working version of label click.

* Put in extra checks, refactored visibility of labels for more general usage.

* Final fixes to code; added to example and in docs

* Adressed review comments

* Add next attempt to fix Travis unit test bug

* Addressed review issues
jittering-top
wimrijnders 7 years ago
committed by Yotam Berkowitz
parent
commit
409e4a355f
10 changed files with 353 additions and 69 deletions
  1. +19
    -1
      docs/network/index.html
  2. +11
    -2
      examples/network/labels/labelAlignment.html
  3. +4
    -7
      lib/network/modules/Canvas.js
  4. +2
    -3
      lib/network/modules/InteractionHandler.js
  5. +58
    -0
      lib/network/modules/SelectionHandler.js
  6. +78
    -15
      lib/network/modules/components/Edge.js
  7. +36
    -0
      lib/network/modules/components/Node.js
  8. +6
    -13
      lib/network/modules/components/edges/util/EdgeBase.js
  9. +73
    -0
      lib/network/modules/components/shared/ComponentUtil.js
  10. +66
    -28
      lib/network/modules/components/shared/Label.js

+ 19
- 1
docs/network/index.html View File

@ -1313,7 +1313,7 @@ var options = {
</td> </td>
<td>Fired when the user clicks the mouse or taps on a touchscreen device. Passes an object with properties structured as: <td>Fired when the user clicks the mouse or taps on a touchscreen device. Passes an object with properties structured as:
<pre class="prettyprint lang-js">{
<pre class="prettyprint lang-js">{
nodes: [Array of selected nodeIds], nodes: [Array of selected nodeIds],
edges: [Array of selected edgeIds], edges: [Array of selected edgeIds],
event: [Object] original click event, event: [Object] original click event,
@ -1323,6 +1323,24 @@ var options = {
} }
} }
</pre> </pre>
This is the structure common to all events. Specifically for the click event, the following property is added:
<pre class="prettyprint lang-js">{
...
items: [Array of click items],
}</pre>
Where the click items can be:
<pre class="prettyprint lang-js">
{nodeId:NodeId} // node with given id clicked on
{nodeId:NodeId labelId:0} // label of node with given id clicked on
{edgeId:EdgeId} // edge with given id clicked on
{edge:EdgeId, labelId:0} // label of edge with given id clicked on
</pre>
The order of the <code>items</code> array is descending in z-order.
Thus, to get the topmost item, get the value at index 0.
</td> </td>
</tr> </tr>
<tr><td id="event_doubleClick">doubleClick</td> <tr><td id="event_doubleClick">doubleClick</td>

+ 11
- 2
examples/network/labels/labelAlignment.html View File

@ -9,7 +9,7 @@
<style type="text/css"> <style type="text/css">
#mynetwork { #mynetwork {
width: 600px; width: 600px;
height: 600px;
height: 400px;
border: 1px solid lightgray; border: 1px solid lightgray;
} }
p { p {
@ -25,8 +25,12 @@
<p>Text-alignment within node labels can be 'left' or 'center', other font alignments not implemented.</p> <p>Text-alignment within node labels can be 'left' or 'center', other font alignments not implemented.</p>
<p>Label alignment (placement of label &quot;box&quot;) for nodes (top, bottom, left, right, inside) is <p>Label alignment (placement of label &quot;box&quot;) for nodes (top, bottom, left, right, inside) is
planned but not in vis yet.</p> planned but not in vis yet.</p>
<p>The click event is captured and displayed to illustrate how the clicking on labels works.
You can drag the nodes over each other to see how this influences the click event values.
</p>
<div id="mynetwork"></div> <div id="mynetwork"></div>
<pre id="eventSpan"></pre>
<script type="text/javascript"> <script type="text/javascript">
// create an array with nodes // create an array with nodes
@ -53,8 +57,13 @@ planned but not in vis yet.

nodes: nodes, nodes: nodes,
edges: edges edges: edges
}; };
var options = {};
var options = {physics:false};
var network = new vis.Network(container, data, options); var network = new vis.Network(container, data, options);
network.on("click", function (params) {
params.event = "[original event]";
document.getElementById('eventSpan').innerHTML = '<h2>Click event:</h2>' + JSON.stringify(params, null, 4);
});
</script> </script>
</body> </body>

+ 4
- 7
lib/network/modules/Canvas.js View File

@ -456,10 +456,8 @@ class Canvas {
/** /**
*
* @param {object} pos = {x: number, y: number}
* @returns {{x: number, y: number}}
* @constructor
* @param {point} pos
* @returns {point}
*/ */
canvasToDOM (pos) { canvasToDOM (pos) {
return {x: this._XconvertCanvasToDOM(pos.x), y: this._YconvertCanvasToDOM(pos.y)}; return {x: this._XconvertCanvasToDOM(pos.x), y: this._YconvertCanvasToDOM(pos.y)};
@ -467,9 +465,8 @@ class Canvas {
/** /**
* *
* @param {object} pos = {x: number, y: number}
* @returns {{x: number, y: number}}
* @constructor
* @param {point} pos
* @returns {point}
*/ */
DOMtoCanvas (pos) { DOMtoCanvas (pos) {
return {x: this._XconvertDOMtoCanvas(pos.x), y: this._YconvertDOMtoCanvas(pos.y)}; return {x: this._XconvertDOMtoCanvas(pos.x), y: this._YconvertDOMtoCanvas(pos.y)};

+ 2
- 3
lib/network/modules/InteractionHandler.js View File

@ -1,8 +1,8 @@
let util = require('../../util'); let util = require('../../util');
var NavigationHandler = require('./components/NavigationHandler').default; var NavigationHandler = require('./components/NavigationHandler').default;
var Popup = require('./../../shared/Popup').default; var Popup = require('./../../shared/Popup').default;
/** /**
* Handler for interactions * Handler for interactions
*/ */
@ -124,6 +124,7 @@ class InteractionHandler {
} }
} }
/** /**
* handle tap/click event: select/unselect a node * handle tap/click event: select/unselect a node
* @param {Event} event * @param {Event} event
@ -150,7 +151,6 @@ class InteractionHandler {
} }
/** /**
* handle long tap event: multi select nodes * handle long tap event: multi select nodes
* @param {Event} event * @param {Event} event
@ -718,7 +718,6 @@ class InteractionHandler {
this.body.emitter.emit('hidePopup'); this.body.emitter.emit('hidePopup');
} }
} }
} }
export default InteractionHandler; export default InteractionHandler;

+ 58
- 0
lib/network/modules/SelectionHandler.js View File

@ -141,6 +141,13 @@ class SelectionHandler {
if (oldSelection !== undefined) { if (oldSelection !== undefined) {
properties['previousSelection'] = oldSelection; properties['previousSelection'] = oldSelection;
} }
if (eventType == 'click') {
// For the time being, restrict this functionality to
// just the click event.
properties.items = this.getClickedItems(pointer);
}
this.body.emitter.emit(eventType, properties); this.body.emitter.emit(eventType, properties);
} }
@ -795,6 +802,57 @@ class SelectionHandler {
} }
} }
} }
/**
* Determine all the visual elements clicked which are on the given point.
*
* All elements are returned; this includes nodes, edges and their labels.
* The order returned is from highest to lowest, i.e. element 0 of the return
* value is the topmost item clicked on.
*
* The return value consists of an array of the following possible elements:
*
* - `{nodeId:number}` - node with given id clicked on
* - `{nodeId:number, labelId:0}` - label of node with given id clicked on
* - `{edgeId:number}` - edge with given id clicked on
* - `{edge:number, labelId:0}` - label of edge with given id clicked on
*
* ## NOTES
*
* - Currently, there is only one label associated with a node or an edge,
* but this is expected to change somewhere in the future.
* - Since there is no z-indexing yet, it is not really possible to set the nodes and
* edges in the correct order. For the time being, nodes come first.
*
* @param {point} pointer mouse position in screen coordinates
* @returns {Array.<nodeClickItem|nodeLabelClickItem|edgeClickItem|edgeLabelClickItem>}
* @private
*/
getClickedItems(pointer) {
let point = this.canvas.DOMtoCanvas(pointer);
var items = [];
// Note reverse order; we want the topmost clicked items to be first in the array
// Also note that selected nodes are disregarded here; these normally display on top
let nodeIndices = this.body.nodeIndices;
let nodes = this.body.nodes;
for (let i = nodeIndices.length - 1; i >= 0; i--) {
let node = nodes[nodeIndices[i]];
let ret = node.getItemsOnPoint(point);
items.push.apply(items, ret); // Append the return value to the running list.
}
let edgeIndices = this.body.edgeIndices;
let edges = this.body.edges;
for (let i = edgeIndices.length - 1; i >= 0; i--) {
let edge = edges[edgeIndices[i]];
let ret = edge.getItemsOnPoint(point);
items.push.apply(items, ret); // Append the return value to the running list.
}
return items;
}
} }
export default SelectionHandler; export default SelectionHandler;

+ 78
- 15
lib/network/modules/components/Edge.js View File

@ -531,7 +531,7 @@ class Edge {
// draw everything // draw everything
this.edgeType.drawLine(ctx, values, this.selected, this.hover, viaNode); this.edgeType.drawLine(ctx, values, this.selected, this.hover, viaNode);
this.drawArrows(ctx, arrowData, values); this.drawArrows(ctx, arrowData, values);
this.drawLabel (ctx, viaNode);
this.drawLabel(ctx, viaNode);
} }
/** /**
@ -572,15 +572,24 @@ class Edge {
var point = this.edgeType.getPoint(0.5, viaNode); var point = this.edgeType.getPoint(0.5, viaNode);
ctx.save(); ctx.save();
// if the label has to be rotated:
if (this.options.font.align !== "horizontal") {
this.labelModule.calculateLabelSize(ctx, this.selected, this.hover, point.x, point.y);
ctx.translate(point.x, this.labelModule.size.yLine);
this._rotateForLabelAlignment(ctx);
let rotationPoint = this._getRotation(ctx);
if (rotationPoint.angle != 0) {
ctx.translate(rotationPoint.x, rotationPoint.y);
ctx.rotate(rotationPoint.angle);
} }
// draw the label // draw the label
this.labelModule.draw(ctx, point.x, point.y, this.selected, this.hover); this.labelModule.draw(ctx, point.x, point.y, this.selected, this.hover);
/*
// Useful debug code: draw a border around the label
// This should **not** be enabled in production!
var size = this.labelModule.getSize();; // ;; intentional so lint catches it
ctx.strokeStyle = "#ff0000";
ctx.strokeRect(size.left, size.top, size.width, size.height);
// End debug code
*/
ctx.restore(); ctx.restore();
} }
else { else {
@ -603,6 +612,36 @@ class Edge {
} }
/**
* Determine all visual elements of this edge instance, in which the given
* point falls within the bounding shape.
*
* @param {point} point
* @returns {Array.<edgeClickItem|edgeLabelClickItem>} list with the items which are on the point
*/
getItemsOnPoint(point) {
var ret = [];
if (this.labelModule.visible()) {
let rotationPoint = this._getRotation();
if (ComponentUtil.pointInRect(this.labelModule.getSize(), point, rotationPoint)) {
ret.push({edgeId:this.id, labelId:0});
}
}
let obj = {
left: point.x,
top: point.y
};
if (this.isOverlappingWith(obj)) {
ret.push({edgeId:this.id});
}
return ret;
}
/** /**
* Check if this object is overlapping with the provided object * Check if this object is overlapping with the provided object
* @param {Object} obj an object with parameters left, top * @param {Object} obj an object with parameters left, top
@ -628,22 +667,46 @@ class Edge {
} }
/**
* Rotates the canvas so the text is most readable
* @param {CanvasRenderingContext2D} ctx
/**
* Determine the rotation point, if any.
*
* @param {CanvasRenderingContext2D} [ctx] if passed, do a recalculation of the label size
* @returns {rotationPoint} the point to rotate around and the angle in radians to rotate
* @private * @private
*/ */
_rotateForLabelAlignment(ctx) {
_getRotation(ctx) {
let viaNode = this.edgeType.getViaNode();
let point = this.edgeType.getPoint(0.5, viaNode);
if (ctx !== undefined) {
this.labelModule.calculateLabelSize(ctx, this.selected, this.hover, point.x, point.y);
}
let ret = {
x: point.x,
y: this.labelModule.size.yLine,
angle: 0
};
if (!this.labelModule.visible()) {
return ret; // Don't even bother doing the atan2, there's nothing to draw
}
if (this.options.font.align === "horizontal") {
return ret; // No need to calculate angle
}
var dy = this.from.y - this.to.y; var dy = this.from.y - this.to.y;
var dx = this.from.x - this.to.x; var dx = this.from.x - this.to.x;
var angleInDegrees = Math.atan2(dy, dx);
var angle = Math.atan2(dy, dx); // radians
// rotate so label it is readable
if ((angleInDegrees < -1 && dx < 0) || (angleInDegrees > 0 && dx < 0)) {
angleInDegrees = angleInDegrees + Math.PI;
// rotate so that label is readable
if ((angle < -1 && dx < 0) || (angle > 0 && dx < 0)) {
angle += Math.PI;
} }
ret.angle = angle;
ctx.rotate(angleInDegrees);
return ret;
} }

+ 36
- 0
lib/network/modules/components/Node.js View File

@ -508,6 +508,16 @@ class Node {
} }
/**
* Get the current dimensions of the label
*
* @return {rect}
*/
getLabelSize() {
return this.labelModule.size();
}
/** /**
* Adjust the value range of the node. The node will adjust it's size * Adjust the value range of the node. The node will adjust it's size
* based on its value. * based on its value.
@ -553,6 +563,7 @@ class Node {
this.shape.updateBoundingBox(this.x,this.y,ctx); this.shape.updateBoundingBox(this.x,this.y,ctx);
} }
/** /**
* Recalculate the size of this node in the given canvas * Recalculate the size of this node in the given canvas
* The 2d context of a HTML canvas can be retrieved by canvas.getContext("2d"); * The 2d context of a HTML canvas can be retrieved by canvas.getContext("2d");
@ -564,6 +575,30 @@ class Node {
} }
/**
* Determine all visual elements of this node instance, in which the given
* point falls within the bounding shape.
*
* @param {point} point
* @returns {Array.<nodeClickItem|nodeLabelClickItem>} list with the items which are on the point
*/
getItemsOnPoint(point) {
var ret = [];
if (this.labelModule.visible()) {
if (ComponentUtil.pointInRect(this.labelModule.getSize(), point)) {
ret.push({nodeId:this.id, labelId:0});
}
}
if (ComponentUtil.pointInRect(this.shape.boundingBox, point)) {
ret.push({nodeId:this.id});
}
return ret;
}
/** /**
* Check if this object is overlapping with the provided object * Check if this object is overlapping with the provided object
* @param {Object} obj an object with parameters left, top, right, bottom * @param {Object} obj an object with parameters left, top, right, bottom
@ -578,6 +613,7 @@ class Node {
); );
} }
/** /**
* Check if this object is overlapping with the provided object * Check if this object is overlapping with the provided object
* @param {Object} obj an object with parameters left, top, right, bottom * @param {Object} obj an object with parameters left, top, right, bottom

+ 6
- 13
lib/network/modules/components/edges/util/EdgeBase.js View File

@ -388,10 +388,11 @@ class EdgeBase {
/** /**
* Calculate the distance between a point (x3,y3) and a line segment from
* (x1,y1) to (x2,y2).
* x3,y3 is the point.
* Calculate the distance between a point (x3,y3) and a line segment from (x1,y1) to (x2,y2).
* (x3,y3) is the point.
*
* http://stackoverflow.com/questions/849211/shortest-distancae-between-a-point-and-a-line-segment * http://stackoverflow.com/questions/849211/shortest-distancae-between-a-point-and-a-line-segment
*
* @param {number} x1 * @param {number} x1
* @param {number} y1 * @param {number} y1
* @param {number} x2 * @param {number} x2
@ -401,7 +402,6 @@ class EdgeBase {
* @param {Node} via * @param {Node} via
* @param {Array} values * @param {Array} values
* @returns {number} * @returns {number}
* @private
*/ */
getDistanceToEdge(x1, y1, x2, y2, x3, y3, via, values) { // eslint-disable-line no-unused-vars getDistanceToEdge(x1, y1, x2, y2, x3, y3, via, values) { // eslint-disable-line no-unused-vars
let returnValue = 0; let returnValue = 0;
@ -415,17 +415,10 @@ class EdgeBase {
returnValue = Math.abs(Math.sqrt(dx * dx + dy * dy) - radius); returnValue = Math.abs(Math.sqrt(dx * dx + dy * dy) - radius);
} }
if (this.labelModule.size.left < x3 &&
this.labelModule.size.left + this.labelModule.size.width > x3 &&
this.labelModule.size.top < y3 &&
this.labelModule.size.top + this.labelModule.size.height > y3) {
return 0;
}
else {
return returnValue;
}
return returnValue;
} }
/** /**
* *
* @param {number} x1 * @param {number} x1

+ 73
- 0
lib/network/modules/components/shared/ComponentUtil.js View File

@ -1,3 +1,23 @@
/**
* Definitions for param's in jsdoc.
* These are more or less global within Network. Putting them here until I can figure out
* where to really put them
*
* @typedef {string|number} Id
* @typedef {Id} NodeId
* @typedef {Id} EdgeId
* @typedef {Id} LabelId
*
* @typedef {{x: number, y: number}} point
* @typedef {{left: number, top: number, width: number, height: number}} rect
* @typedef {{x: number, y:number, angle: number}} rotationPoint
* - point to rotate around and the angle in radians to rotate. angle == 0 means no rotation
* @typedef {{nodeId:NodeId}} nodeClickItem
* @typedef {{nodeId:NodeId, labelId:LabelId}} nodeLabelClickItem
* @typedef {{edgeId:EdgeId}} edgeClickItem
* @typedef {{edgeId:EdgeId, labelId:LabelId}} edgeLabelClickItem
*/
let util = require("../../../../util"); let util = require("../../../../util");
/** /**
@ -49,6 +69,59 @@ class ComponentUtil {
return value; return value;
} }
/**
* Check if the point falls within the given rectangle.
*
* @param {rect} rect
* @param {point} point
* @param {rotationPoint} [rotationPoint] if specified, the rotation that applies to the rectangle.
* @returns {boolean} true if point within rectangle, false otherwise
* @static
*/
static pointInRect(rect, point, rotationPoint) {
if (rect.width <= 0 || rect.height <= 0) {
return false; // early out
}
if (rotationPoint !== undefined) {
// Rotate the point the same amount as the rectangle
var tmp = {
x: point.x - rotationPoint.x,
y: point.y - rotationPoint.y
};
if (rotationPoint.angle !== 0) {
// In order to get the coordinates the same, you need to
// rotate in the reverse direction
var angle = -rotationPoint.angle;
var tmp2 = {
x: Math.cos(angle)*tmp.x - Math.sin(angle)*tmp.y,
y: Math.sin(angle)*tmp.x + Math.cos(angle)*tmp.y
};
point = tmp2;
} else {
point = tmp;
}
// Note that if a rotation is specified, the rectangle coordinates
// are **not* the full canvas coordinates. They are relative to the
// rotationPoint. Hence, the point coordinates need not be translated
// back in this case.
}
var right = rect.x + rect.width;
var bottom = rect.y + rect.width;
return (
rect.left < point.x &&
right > point.x &&
rect.top < point.y &&
bottom > point.y
);
}
} }
export default ComponentUtil; export default ComponentUtil;

+ 66
- 28
lib/network/modules/components/shared/Label.js View File

@ -51,7 +51,7 @@ class Label {
this.baseSize = undefined; this.baseSize = undefined;
this.fontOptions = {}; // instance variable containing the *instance-local* font options this.fontOptions = {}; // instance variable containing the *instance-local* font options
this.setOptions(options); this.setOptions(options);
this.size = {top: 0, left: 0, width: 0, height: 0, yLine: 0}; // could be cached
this.size = {top: 0, left: 0, width: 0, height: 0, yLine: 0};
this.isEdgeLabel = edgelabel; this.isEdgeLabel = edgelabel;
} }
@ -439,42 +439,21 @@ class Label {
// update the size cache if required // update the size cache if required
this.calculateLabelSize(ctx, selected, hover, x, y, baseline); this.calculateLabelSize(ctx, selected, hover, x, y, baseline);
this._drawBackground(ctx, this.size.left, this.size.top);
this._drawBackground(ctx);
this._drawText(ctx, x, this.size.yLine, baseline, viewFontSize); this._drawText(ctx, x, this.size.yLine, baseline, viewFontSize);
} }
/** /**
* Draws the label background * Draws the label background
* @param {CanvasRenderingContext2D} ctx * @param {CanvasRenderingContext2D} ctx
* @param {number} x
* @param {number} y
* @private * @private
*/ */
_drawBackground(ctx, x, y) {
_drawBackground(ctx) {
if (this.fontOptions.background !== undefined && this.fontOptions.background !== "none") { if (this.fontOptions.background !== undefined && this.fontOptions.background !== "none") {
ctx.fillStyle = this.fontOptions.background; ctx.fillStyle = this.fontOptions.background;
let lineMargin = 2;
if (this.isEdgeLabel) {
switch (this.fontOptions.align) {
case 'middle':
ctx.fillRect(-this.size.width * 0.5, -this.size.height * 0.5, this.size.width, this.size.height);
break;
case 'top':
ctx.fillRect(-this.size.width * 0.5, -(this.size.height + lineMargin), this.size.width, this.size.height);
break;
case 'bottom':
ctx.fillRect(-this.size.width * 0.5, lineMargin, this.size.width, this.size.height);
break;
default:
ctx.fillRect(x, y - 0.5*lineMargin, this.size.width, this.size.height);
break;
}
} else {
ctx.fillRect(x, y - 0.5*lineMargin, this.size.width, this.size.height);
}
let size = this.getSize();
ctx.fillRect(size.left, size.top, size.width, size.height);
} }
} }
@ -489,7 +468,6 @@ class Label {
* @private * @private
*/ */
_drawText(ctx, x, y, baseline = 'middle', viewFontSize) { _drawText(ctx, x, y, baseline = 'middle', viewFontSize) {
[x, y] = this._setAlignment(ctx, x, y, baseline); [x, y] = this._setAlignment(ctx, x, y, baseline);
ctx.textAlign = 'left'; ctx.textAlign = 'left';
@ -608,6 +586,46 @@ class Label {
} }
/**
* Get the current dimensions of the label
*
* @return {rect}
*/
getSize() {
let lineMargin = 2;
let x = this.size.left; // default values which might be overridden below
let y = this.size.top - 0.5*lineMargin; // idem
if (this.isEdgeLabel) {
const x2 = -this.size.width * 0.5;
switch (this.fontOptions.align) {
case 'middle':
x = x2;
y = -this.size.height * 0.5
break;
case 'top':
x = x2;
y = -(this.size.height + lineMargin);
break;
case 'bottom':
x = x2;
y = lineMargin;
break;
}
}
var ret = {
left : x,
top : y,
width : this.size.width,
height: this.size.height,
};
return ret;
}
/** /**
* *
* @param {CanvasRenderingContext2D} ctx * @param {CanvasRenderingContext2D} ctx
@ -744,6 +762,26 @@ class Label {
this.labelDirty = false; this.labelDirty = false;
} }
/**
* Check if this label is visible
*
* @return {boolean} true if this label will be show, false otherwise
*/
visible() {
if ((this.size.width === 0 || this.size.height === 0)
|| this.elementOptions.label === undefined) {
return false; // nothing to display
}
let viewFontSize = this.fontOptions.size * this.body.view.scale;
if (viewFontSize < this.elementOptions.scaling.label.drawThreshold - 1) {
return false; // Too small or too far away to show
}
return true;
}
} }
export default Label; export default Label;

Loading…
Cancel
Save