├── 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 | [
](https://bl.ocks.org/rpgove/8c8b08cc0ae1e1e969f5d2904a6a0e26)[
](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
--------------------------------------------------------------------------------