├── LICENSE ├── README.md ├── greadability.js └── img ├── bestparameters.png └── convergence.png /LICENSE: -------------------------------------------------------------------------------- 1 | BSD 3-Clause License 2 | 3 | Copyright (c) 2016, Robert Gove 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions are met: 8 | 9 | * Redistributions of source code must retain the above copyright notice, this 10 | list of conditions and the following disclaimer. 11 | 12 | * Redistributions in binary form must reproduce the above copyright notice, 13 | this list of conditions and the following disclaimer in the documentation 14 | and/or other materials provided with the distribution. 15 | 16 | * Neither the name of the copyright holder nor the names of its 17 | contributors may be used to endorse or promote products derived from 18 | this software without specific prior written permission. 19 | 20 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 21 | AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 22 | IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 23 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE 24 | FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL 25 | DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR 26 | SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER 27 | CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, 28 | OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 29 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Greadability.js 2 | 3 | **Greadability.js** is a JavaScript library for computing global **g**raph **readability** metrics on graph layouts. These readability metrics help us answer questions like, which layout is better? Or, has the layout converged, or should it continue running? 4 | 5 | At present, Greadability.js includes four global graph readability metrics: 6 | 7 | * *Edge crossings* measures the fraction of edges that cross (intersect) out of an approximate maximum number that can cross. 8 | * *Edge crossing angle* measures the mean deviation of edge crossing angles from the ideal edge crossing angle (70 degrees). 9 | * *Angular resolution (minimum)* measures the mean deviation of adjacent incident edge angles from the ideal minimum angles (360 degrees divided by the degree of that node). 10 | * *Angular resoluction (deviation)* measures the average deviation of angles between incident 11 | edges on each vertex. 12 | 13 | Each is a number in the range [0, 1] with higher numbers indicating better layouts. You can use this to measure when a graph layout algorithm has stopped improving (i.e. when it has [converged](https://bl.ocks.org/rpgove/8c8b08cc0ae1e1e969f5d2904a6a0e26)), or to find [good graph layout algorithm parameters](https://bl.ocks.org/rpgove/553450ed8ef2a48acd4121a85653d880). 14 | 15 | [Force Directed Layout Quality Convergence](https://bl.ocks.org/rpgove/8c8b08cc0ae1e1e969f5d2904a6a0e26)[Automatically Finding Better Force Directed Layout Parameters (10x10 Grid)](https://bl.ocks.org/rpgove/553450ed8ef2a48acd4121a85653d880) 16 | 17 | To use this module, create a layout for a graph (e.g. using [D3.js](https://d3js.org)) so that each vertex (also known as a *node*) has `x` and `y` properties for its coordinates and each edge (also known as a *link*) has `source` and `target` properties that point to vertices. 18 | 19 | If you use this library please cite the following paper for the definition of the angular resolution (deviation) metric and the proof that it yields values in the range [0, 1]: 20 | 21 | Robert Gove. "It Pays to Be Lazy: Reusing Force Approximations to Compute Better Graph Layouts Faster." Proceedings of Forum Media Technology, 2018. [Preprint PDF.](https://osf.io/wgzn5/) 22 | 23 | For the other metrics and a general discussion of graph layout readability metrics, see Dunne *et al* and [their earlier tech report](http://www.cs.umd.edu/hcil/trs/2009-13/2009-13.pdf): 24 | 25 | C. Dunne, S. I. Ross, B. Shneiderman, and M. Martino. "Readability metric feedback for aiding node-link visualization designers," IBM Journal of Research and Development, 59(2/3) pages 14:1--14:16, 2015. 26 | 27 | ## Installing 28 | 29 | Download the latest version from the [Greadability.js GitHub repository](https://github.com/rpgove/greadability/releases). 30 | 31 | You can then use it in a webpage, like this: 32 | 33 | ```html 34 | 35 | 50 | ``` 51 | 52 | Or similarly in Node.js: 53 | 54 | ```js 55 | const greadability = require('./greadability.js'); 56 | 57 | var simulation = d3.forceSimulation() 58 | .force("link", d3.forceLink().id(function(d) { return d.id; }).links(graph.links)) 59 | .force("charge", d3.forceManyBody()) 60 | .nodes(graph.nodes) 61 | .on("end", computeReadability); 62 | 63 | function computeReadability () { 64 | var nodes = simulation.nodes(); 65 | var links = simulation.force("link").links(); 66 | console.log(greadability.greadability(nodes, links)); 67 | } 68 | ``` 69 | 70 | ## API Reference 71 | 72 | # greadability.greadability(nodes, links[, id]) [<>](https://github.com/rpgove/greadability/blob/master/greadability.js#L7 "Source") 73 | 74 | Computes the readability metrics of the graph formed by the *nodes* and *links*. Each node in *nodes* must have `x` and `y` attributes specifying each node's position. This function returns an object with the readability metrics as the properties and values: 75 | 76 | ```javascript 77 | { 78 | crossing: 1, 79 | crossingAngle: 0.7, 80 | angularResolutionMin: 0.34, 81 | angularResolutionDev: 0.56 82 | } 83 | ``` 84 | 85 | If *id* is specified, sets the node id accessor to the specified function. If *id* is not specified, uses the default node id accessor, which defaults to the node's index. Note that if each link's `source` and `target` properties are objects, then the node id accessor is not used. This is the same behavior as the forceSimulation in D3.js. 86 | -------------------------------------------------------------------------------- /greadability.js: -------------------------------------------------------------------------------- 1 | (function (global, factory) { 2 | typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports) : 3 | typeof define === 'function' && define.amd ? define(['exports'], factory) : 4 | (factory((global.greadability = global.greadability || {}))); 5 | }(this, (function (exports) { 'use strict'; 6 | 7 | var greadability = function (nodes, links, id) { 8 | var i, 9 | j, 10 | n = nodes.length, 11 | m, 12 | degree = new Array(nodes.length), 13 | cMax, 14 | idealAngle = 70, 15 | dMax; 16 | 17 | /* 18 | * Tracks the global graph readability metrics. 19 | */ 20 | var graphStats = { 21 | crossing: 0, // Normalized link crossings 22 | crossingAngle: 0, // Normalized average dev from 70 deg 23 | angularResolutionMin: 0, // Normalized avg dev from ideal min angle 24 | angularResolutionDev: 0, // Normalized avg dev from each link 25 | }; 26 | 27 | var getSumOfArray = function (numArray) { 28 | var i = 0, n = numArray.length, sum = 0; 29 | for (; i < n; ++i) sum += numArray[i]; 30 | return sum; 31 | }; 32 | 33 | var initialize = function () { 34 | var i, j, link; 35 | var nodeById = {}; 36 | // Filter out self loops 37 | links = links.filter(function (l) { 38 | return l.source !== l.target; 39 | }); 40 | 41 | m = links.length; 42 | 43 | if (!id) { 44 | id = function (d) { return d.index; }; 45 | } 46 | 47 | for (i = 0; i < n; ++i) { 48 | nodes[i].index = i; 49 | degree[i] = []; 50 | nodeById[id(nodes[i], i, nodeById)] = nodes[i]; 51 | } 52 | 53 | // Make sure source and target are nodes and not indices. 54 | for (i = 0; i < m; ++i) { 55 | link = links[i]; 56 | if (typeof link.source !== "object") link.source = nodeById[link.source]; 57 | if (typeof link.target !== "object") link.target = nodeById[link.target]; 58 | } 59 | 60 | // Filter out duplicate links 61 | var filteredLinks = []; 62 | links.forEach(function (l) { 63 | var s = l.source, t = l.target; 64 | if (s.index > t.index) { 65 | filteredLinks.push({source: t, target: s}); 66 | } else { 67 | filteredLinks.push({source: s, target: t}); 68 | } 69 | }); 70 | links = filteredLinks; 71 | links.sort(function (a, b) { 72 | if (a.source.index < b.source.index) return -1; 73 | if (a.source.index > b.source.index) return 1; 74 | if (a.target.index < b.target.index) return -1; 75 | if (a.target.index > b.target.index) return 1; 76 | return 0; 77 | }); 78 | i = 1; 79 | while (i < links.length) { 80 | if (links[i-1].source.index === links[i].source.index && 81 | links[i-1].target.index === links[i].target.index) { 82 | links.splice(i, 1); 83 | } 84 | else ++i; 85 | } 86 | 87 | // Update length, if a duplicate was deleted. 88 | m = links.length; 89 | 90 | // Calculate degree. 91 | for (i = 0; i < m; ++i) { 92 | link = links[i]; 93 | link.index = i; 94 | 95 | degree[link.source.index].push(link); 96 | degree[link.target.index].push(link); 97 | }; 98 | } 99 | 100 | // Assume node.x and node.y are the coordinates 101 | 102 | function direction (pi, pj, pk) { 103 | var p1 = [pk[0] - pi[0], pk[1] - pi[1]]; 104 | var p2 = [pj[0] - pi[0], pj[1] - pi[1]]; 105 | return p1[0] * p2[1] - p2[0] * p1[1]; 106 | } 107 | 108 | // Is point k on the line segment formed by points i and j? 109 | // Inclusive, so if pk == pi or pk == pj then return true. 110 | function onSegment (pi, pj, pk) { 111 | return Math.min(pi[0], pj[0]) <= pk[0] && 112 | pk[0] <= Math.max(pi[0], pj[0]) && 113 | Math.min(pi[1], pj[1]) <= pk[1] && 114 | pk[1] <= Math.max(pi[1], pj[1]); 115 | } 116 | 117 | function linesCross (line1, line2) { 118 | var d1, d2, d3, d4; 119 | 120 | // CLRS 2nd ed. pg. 937 121 | d1 = direction(line2[0], line2[1], line1[0]); 122 | d2 = direction(line2[0], line2[1], line1[1]); 123 | d3 = direction(line1[0], line1[1], line2[0]); 124 | d4 = direction(line1[0], line1[1], line2[1]); 125 | 126 | if (((d1 > 0 && d2 < 0) || (d1 < 0 && d2 > 0)) && 127 | ((d3 > 0 && d4 < 0) || (d3 < 0 && d4 > 0))) { 128 | return true; 129 | } else if (d1 === 0 && onSegment(line2[0], line2[1], line1[0])) { 130 | return true; 131 | } else if (d2 === 0 && onSegment(line2[0], line2[1], line1[1])) { 132 | return true; 133 | } else if (d3 === 0 && onSegment(line1[0], line1[1], line2[0])) { 134 | return true; 135 | } else if (d4 === 0 && onSegment(line1[0], line1[1], line2[1])) { 136 | return true; 137 | } 138 | 139 | return false; 140 | } 141 | 142 | function linksCross (link1, link2) { 143 | // Self loops are not intersections 144 | if (link1.index === link2.index || 145 | link1.source === link1.target || 146 | link2.source === link2.target) { 147 | return false; 148 | } 149 | 150 | // Links cannot intersect if they share a node 151 | if (link1.source === link2.source || 152 | link1.source === link2.target || 153 | link1.target === link2.source || 154 | link1.target === link2.target) { 155 | return false; 156 | } 157 | 158 | var line1 = [ 159 | [link1.source.x, link1.source.y], 160 | [link1.target.x, link1.target.y] 161 | ]; 162 | 163 | var line2 = [ 164 | [link2.source.x, link2.source.y], 165 | [link2.target.x, link2.target.y] 166 | ]; 167 | 168 | return linesCross(line1, line2); 169 | } 170 | 171 | function linkCrossings () { 172 | var i, j, c = 0, d = 0, link1, link2, line1, line2;; 173 | 174 | // Sum the upper diagonal of the edge crossing matrix. 175 | for (i = 0; i < m; ++i) { 176 | for (j = i + 1; j < m; ++j) { 177 | link1 = links[i], link2 = links[j]; 178 | 179 | // Check if link i and link j intersect 180 | if (linksCross(link1, link2)) { 181 | line1 = [ 182 | [link1.source.x, link1.source.y], 183 | [link1.target.x, link1.target.y] 184 | ]; 185 | line2 = [ 186 | [link2.source.x, link2.source.y], 187 | [link2.target.x, link2.target.y] 188 | ]; 189 | ++c; 190 | d += Math.abs(idealAngle - acuteLinesAngle(line1, line2)); 191 | } 192 | } 193 | } 194 | 195 | return {c: 2*c, d: 2*d}; 196 | } 197 | 198 | function linesegmentsAngle (line1, line2) { 199 | // Finds the (counterclockwise) angle from line segement line1 to 200 | // line segment line2. Assumes the lines share one end point. 201 | // If both endpoints are the same, or if both lines have zero 202 | // length, then return 0 angle. 203 | // Param order matters: 204 | // linesegmentsAngle(line1, line2) != linesegmentsAngle(line2, line1) 205 | var temp, len, angle1, angle2, sLine1, sLine2; 206 | 207 | // Re-orient so that line1[0] and line2[0] are the same. 208 | if (line1[0][0] === line2[1][0] && line1[0][1] === line2[1][1]) { 209 | temp = line2[1]; 210 | line2[1] = line2[0]; 211 | line2[0] = temp; 212 | } else if (line1[1][0] === line2[0][0] && line1[1][1] === line2[0][1]) { 213 | temp = line1[1]; 214 | line1[1] = line1[0]; 215 | line1[0] = temp; 216 | } else if (line1[1][0] === line2[1][0] && line1[1][1] === line2[1][1]) { 217 | temp = line1[1]; 218 | line1[1] = line1[0]; 219 | line1[0] = temp; 220 | temp = line2[1]; 221 | line2[1] = line2[0]; 222 | line2[0] = temp; 223 | } 224 | 225 | // Shift the line so that the first point is at (0,0). 226 | sLine1 = [ 227 | [line1[0][0] - line1[0][0], line1[0][1] - line1[0][1]], 228 | [line1[1][0] - line1[0][0], line1[1][1] - line1[0][1]] 229 | ]; 230 | // Normalize the line length. 231 | len = Math.hypot(sLine1[1][0], sLine1[1][1]); 232 | if (len === 0) return 0; 233 | sLine1[1][0] /= len; 234 | sLine1[1][1] /= len; 235 | // If y < 0, angle = acos(x), otherwise angle = 360 - acos(x) 236 | angle1 = Math.acos(sLine1[1][0]) * 180 / Math.PI; 237 | if (sLine1[1][1] < 0) angle1 = 360 - angle1; 238 | 239 | // Shift the line so that the first point is at (0,0). 240 | sLine2 = [ 241 | [line2[0][0] - line2[0][0], line2[0][1] - line2[0][1]], 242 | [line2[1][0] - line2[0][0], line2[1][1] - line2[0][1]] 243 | ]; 244 | // Normalize the line length. 245 | len = Math.hypot(sLine2[1][0], sLine2[1][1]); 246 | if (len === 0) return 0; 247 | sLine2[1][0] /= len; 248 | sLine2[1][1] /= len; 249 | // If y < 0, angle = acos(x), otherwise angle = 360 - acos(x) 250 | angle2 = Math.acos(sLine2[1][0]) * 180 / Math.PI; 251 | if (sLine2[1][1] < 0) angle2 = 360 - angle2; 252 | 253 | return angle1 <= angle2 ? angle2 - angle1 : 360 - (angle1 - angle2); 254 | } 255 | 256 | function acuteLinesAngle (line1, line2) { 257 | // Acute angle of intersection, in degrees. Assumes these lines 258 | // intersect. 259 | var slope1 = (line1[1][1] - line1[0][1]) / (line1[1][0] - line1[0][0]); 260 | var slope2 = (line2[1][1] - line2[0][1]) / (line2[1][0] - line2[0][0]); 261 | 262 | // If these lines are two links incident on the same node, need 263 | // to check if the angle is 0 or 180. 264 | if (slope1 === slope2) { 265 | // If line2 is not on line1 and line1 is not on line2, then 266 | // the lines share only one point and the angle must be 180. 267 | if (!(onSegment(line1[0], line1[1], line2[0]) && onSegment(line1[0], line1[1], line2[1])) || 268 | !(onSegment(line2[0], line2[1], line1[0]) && onSegment(line2[0], line2[1], line1[1]))) 269 | return 180; 270 | else return 0; 271 | } 272 | 273 | var angle = Math.abs(Math.atan(slope1) - Math.atan(slope2)); 274 | 275 | return (angle > Math.PI / 2 ? Math.PI - angle : angle) * 180 / Math.PI; 276 | } 277 | 278 | function angularRes () { 279 | var j, 280 | resMin = 0, 281 | resDev = 0, 282 | nonZeroDeg, 283 | node, 284 | minAngle, 285 | idealMinAngle, 286 | incident, 287 | line0, 288 | line1, 289 | line2, 290 | incidentLinkAngles, 291 | nextLink; 292 | 293 | nonZeroDeg = degree.filter(function (d) { return d.length >= 1; }).length; 294 | 295 | for (j = 0; j < n; ++j) { 296 | node = nodes[j]; 297 | line0 = [[node.x, node.y], [node.x+1, node.y]]; 298 | 299 | // Links that are incident to this node (already filtered out self loops) 300 | incident = degree[j]; 301 | 302 | if (incident.length <= 1) continue; 303 | 304 | idealMinAngle = 360 / incident.length; 305 | 306 | // Sort edges by the angle they make from an imaginary vector 307 | // emerging at angle 0 on the unit circle. 308 | // Necessary for calculating angles of incident edges correctly 309 | incident.sort(function (a, b) { 310 | line1 = [ 311 | [a.source.x, a.source.y], 312 | [a.target.x, a.target.y] 313 | ]; 314 | line2 = [ 315 | [b.source.x, b.source.y], 316 | [b.target.x, b.target.y] 317 | ]; 318 | var angleA = linesegmentsAngle(line0, line1); 319 | var angleB = linesegmentsAngle(line0, line2); 320 | return angleA < angleB ? -1 : angleA > angleB ? 1 : 0; 321 | }); 322 | 323 | incidentLinkAngles = incident.map(function (l, i) { 324 | nextLink = incident[(i + 1) % incident.length]; 325 | line1 = [ 326 | [l.source.x, l.source.y], 327 | [l.target.x, l.target.y] 328 | ]; 329 | line2 = [ 330 | [nextLink.source.x, nextLink.source.y], 331 | [nextLink.target.x, nextLink.target.y] 332 | ]; 333 | return linesegmentsAngle(line1, line2); 334 | }); 335 | 336 | minAngle = Math.min.apply(null, incidentLinkAngles); 337 | 338 | resMin += Math.abs(idealMinAngle - minAngle) / idealMinAngle; 339 | 340 | resDev += getSumOfArray(incidentLinkAngles.map(function (angle) { 341 | return Math.abs(idealMinAngle - angle) / idealMinAngle; 342 | })) / (2 * incident.length - 2); 343 | } 344 | 345 | // Divide by number of nodes with degree != 0 346 | resMin = resMin / nonZeroDeg; 347 | 348 | // Divide by number of nodes with degree != 0 349 | resDev = resDev / nonZeroDeg; 350 | 351 | return {resMin: resMin, resDev: resDev}; 352 | } 353 | 354 | initialize(); 355 | 356 | cMax = (m * (m - 1) / 2) - getSumOfArray(degree.map(function (d) { return d.length * (d.length - 1); })) / 2; 357 | 358 | var crossInfo = linkCrossings(); 359 | 360 | dMax = crossInfo.c * idealAngle; 361 | 362 | graphStats.crossing = 1 - (cMax > 0 ? crossInfo.c / cMax : 0); 363 | 364 | graphStats.crossingAngle = 1 - (dMax > 0 ? crossInfo.d / dMax : 0); 365 | 366 | var angularResInfo = angularRes(); 367 | 368 | graphStats.angularResolutionMin = 1 - angularResInfo.resMin; 369 | 370 | graphStats.angularResolutionDev = 1 - angularResInfo.resDev; 371 | 372 | return graphStats; 373 | }; 374 | 375 | exports.greadability = greadability; 376 | 377 | Object.defineProperty(exports, '__esModule', { value: true }); 378 | 379 | }))); 380 | -------------------------------------------------------------------------------- /img/bestparameters.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rpgove/greadability.js/20bdbbf9fb8a902be253ee41681da23ef11b52ef/img/bestparameters.png -------------------------------------------------------------------------------- /img/convergence.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rpgove/greadability.js/20bdbbf9fb8a902be253ee41681da23ef11b52ef/img/convergence.png --------------------------------------------------------------------------------