Difference between revisions of "MediaWiki:Network-graph.js"
Jump to navigation
Jump to search
(33 intermediate revisions by the same user not shown) | |||
Line 1: | Line 1: | ||
/* | /* | ||
− | Name: | + | Name: MediaWiki:Network-graph.js |
Purpose: Build the network graph for the page being displayed | Purpose: Build the network graph for the page being displayed | ||
Parameters: title = the internal title of the reference (e.g., Cerebral_hemispheric_lateralization_in_cardiac_autonomic_control) | Parameters: title = the internal title of the reference (e.g., Cerebral_hemispheric_lateralization_in_cardiac_autonomic_control) | ||
depth = the number of levels to include in the graph (1 - 9) | depth = the number of levels to include in the graph (1 - 9) | ||
Author: Alan O'Neill | Author: Alan O'Neill | ||
− | Release Date: | + | Release Date: September 23, 2019 |
*/ | */ | ||
− | var Ctrl | + | var Json; // populated once when the page loads |
− | + | var Ctrl; // rebuilt each time the graph is rendered on a given page (e.g., when resizing) | |
− | |||
− | |||
− | |||
− | + | function init() { | |
− | + | Ctrl = { | |
− | + | json: {}, | |
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | + | canvas: { | |
− | + | width: $('#network-graph').width(), | |
− | + | height: $('#network-graph').width(), | |
− | + | borderColor: 'black', | |
− | + | borderWidth: '1px' | |
− | + | }, | |
− | |||
− | |||
− | + | color: [ | |
− | + | '#e6194B', // red | |
− | + | '#f58231', // orange | |
− | + | '#ffe119', // yellow | |
− | + | '#3cb44b', // green | |
+ | '#42d4f4', // cyan | ||
+ | '#4363d8', // blue | ||
+ | '#000075', // navy | ||
+ | '#f032e6', // magenta | ||
+ | '#800000', // maroon | ||
+ | '#9a6324', // brown | ||
+ | ], | ||
− | + | tocColor: '#48a5d1', | |
− | |||
− | |||
− | |||
− | + | circle: { | |
− | + | radius: 14, | |
− | + | textXOffset: -4, | |
− | + | textYOffset: 4, | |
− | + | fontSize: '12px', | |
− | + | fontColor: 'white', | |
+ | fontWeight: 'bold', | ||
+ | }, | ||
− | force: null, | + | arrow: { |
− | + | size: 6, | |
+ | strokeWidth: 1, // 0 = no arrow | ||
+ | color: 'grey', | ||
+ | }, | ||
+ | |||
+ | line: { | ||
+ | strokeWidth: 2, | ||
+ | color: 'grey', | ||
+ | }, | ||
+ | |||
+ | physical: { | ||
+ | charge: -10, | ||
+ | linkDistance: 10, | ||
+ | distance: 120, | ||
+ | gravity: .02, | ||
+ | }, | ||
+ | |||
+ | force: null, | ||
+ | svg: null, | ||
+ | } | ||
+ | |||
+ | $.extend(Ctrl.json, Json); | ||
} | } | ||
Line 67: | Line 80: | ||
} | } | ||
); | ); | ||
+ | } | ||
+ | |||
+ | function inBounds(n) { | ||
+ | // Adjust a coordinate so it is within the bounds of the canvas (remember, it's always square) | ||
+ | // n - the number to adjust | ||
+ | // Return the adjusted value | ||
+ | |||
+ | var min = Ctrl.circle.radius * 3; | ||
+ | var max = Ctrl.canvas.width - min; | ||
+ | return Math.min(Math.max(n, min), max); | ||
} | } | ||
Line 87: | Line 110: | ||
function getBaseUrl() { | function getBaseUrl() { | ||
− | // Return the base URL for the site (e.g., 'https:// | + | // Return the base URL for the site (e.g., 'https://sudepwiki.pathology.jhmi.edu') |
var baseUrl = location.protocol + '//' + location.host + location.pathname; | var baseUrl = location.protocol + '//' + location.host + location.pathname; | ||
− | + | var i = baseUrl.indexOf('/index.php'); | |
+ | if (i < 0) { i = baseUrl.indexOf('/site-links'); } | ||
+ | baseUrl = baseUrl.substr(0, i); | ||
return baseUrl; | return baseUrl; | ||
Line 103: | Line 128: | ||
title = location.pathname.substr(location.pathname.indexOf(delim) + delim.length); // e.g., Cerebral_hemispheric_lateralization_in_cardiac_autonomic_control | title = location.pathname.substr(location.pathname.indexOf(delim) + delim.length); // e.g., Cerebral_hemispheric_lateralization_in_cardiac_autonomic_control | ||
} | } | ||
+ | title = decodeURIComponent(title); | ||
return title; | return title; | ||
Line 110: | Line 136: | ||
// Return the depth to use for the graph (?) | // Return the depth to use for the graph (?) | ||
− | + | var depth = 9; | |
− | |||
− | + | if (depth < 1) { depth = 1; } | |
− | + | else if (depth > 9) { depth = 9; } | |
− | |||
− | |||
− | |||
− | |||
− | |||
− | + | return depth; | |
− | |||
− | |||
} | } | ||
Line 131: | Line 149: | ||
// index - the index of the node that was double clicked | // index - the index of the node that was double clicked | ||
− | window.location.href = getBaseUrl() + '/index.php/' + node.name; | + | // When node.level is zero, the node refers to the page being displayed |
+ | if (node.level > 0) { | ||
+ | window.location.href = getBaseUrl() + '/index.php/' + node.name; | ||
+ | } else { | ||
+ | alert("You're already viewing this page."); | ||
+ | } | ||
}; | }; | ||
− | function | + | function loadData(onSuccess) { |
− | + | $('#network-graph').html('Retrieving data for the network graph...'); | |
− | |||
− | if ( | + | $.ajax({ |
− | + | method: 'get', | |
+ | url: getBaseUrl() + '/site-links/network-graph.php', | ||
+ | data: { depth: getDepth(), title: getTitle() }, | ||
+ | dataType: 'json', | ||
+ | success: function(data, textStatus, jqXHR) { | ||
+ | if (data == 0) { | ||
+ | $('#network-graph').html('No data is available for this graph.'); | ||
+ | } else { | ||
+ | Json = data; | ||
+ | onSuccess(); | ||
+ | } | ||
+ | }, | ||
+ | error: function(jqXHR, textStatus, errorThrown) { | ||
+ | $('#network-graph').html('An error occurred while retrieving data for this graph.'); | ||
+ | console.log('loadData: jqXHR =', jqXHR, '; textStatus =', textStatus, '; errorThrown =', errorThrown); | ||
+ | }, | ||
+ | }); | ||
+ | } | ||
− | + | function renderGraph() { | |
− | + | if (typeof Json !== 'undefined') { | |
− | + | $('#network-graph').html('Hover over a node to see the title of the article. Double-click on a node to view the article.<br />'); | |
− | |||
− | |||
− | + | init(); | |
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | |||
− | + | // Adjust the canvas size based on the number of nodes | |
− | + | var nodes = Json.nodes.length; | |
− | + | if (nodes > 200) { Ctrl.circle.radius *= 2 / 3; } | |
− | + | var max = Ctrl.canvas.width; | |
− | . | + | var min = Math.sqrt(nodes) * Ctrl.circle.radius * 10; |
− | + | if (min < max) { Ctrl.canvas.width = Ctrl.canvas.height = min; } | |
− | |||
− | |||
− | + | // Create the canvas | |
− | + | Ctrl.svg = d3.select('#network-graph') | |
− | + | .append('svg') | |
− | + | .attr('width', Ctrl.canvas.width) | |
− | + | .attr('height', Ctrl.canvas.height); | |
− | |||
− | |||
− | |||
− | + | // Define the arrow head | |
+ | var depth = getDepth(); | ||
+ | for (var level = 0; level < depth; level++ ) { | ||
+ | let fullSize = Ctrl.arrow.size; | ||
+ | let halfSize = Math.floor(fullSize / 2); | ||
Ctrl.svg | Ctrl.svg | ||
− | .selectAll(' | + | .append('defs') |
− | .data( | + | .selectAll('marker') |
+ | .data(['arrow' + level]) | ||
.enter() | .enter() | ||
− | .append(' | + | .append('marker') |
− | .attr(' | + | .attr('id', function(node, index) { return node; }) // e.g., 'arrow0' |
− | . | + | .attr('viewBox', '0 -' + halfSize + ' ' + fullSize + ' ' + fullSize) |
− | . | + | .attr('refX', function(node, index) { return Math.floor(Ctrl.circle.radius / 2) + Ctrl.arrow.size; }) |
− | . | + | .attr('refY', 0) |
+ | .attr('markerWidth', fullSize) | ||
+ | .attr('markerHeight', fullSize) | ||
+ | .attr('orient', 'auto') | ||
+ | .append('path') | ||
+ | .attr('d', function(node, index) { return 'M0,-' + halfSize + 'L' + fullSize + ',0L0,' + halfSize + 'L' + fullSize + ',0L0,-' + halfSize; }) | ||
+ | .style('stroke', Ctrl.arrow.color) | ||
+ | .style('stroke-width', Ctrl.arrow.strokeWidth) | ||
+ | } | ||
− | // Add the nodes (circles) | + | // Define the characteristics of the force simulation |
− | + | Ctrl.force = d3.layout | |
− | + | .force() | |
− | + | .size([Ctrl.canvas.width, Ctrl.canvas.height]) | |
− | + | .charge(Ctrl.physical.charge) | |
− | + | .linkDistance(Ctrl.physical.linkDistance) | |
− | + | .distance(Ctrl.physical.distance) | |
− | + | .gravity(Ctrl.physical.gravity); | |
− | + | ||
− | + | // Introduce the data to the force simulation | |
− | + | Ctrl.force | |
− | . | + | .nodes(Ctrl.json.nodes) |
+ | .links(Ctrl.json.links); | ||
+ | |||
+ | // Add the links (lines and arrows) | ||
+ | Ctrl.svg | ||
+ | .selectAll('.link') | ||
+ | .data(Ctrl.json.links) | ||
+ | .enter() | ||
+ | .append('line') | ||
+ | .attr('class', 'link') | ||
+ | .style('stroke', Ctrl.line.color) | ||
+ | .style('stroke-width', Ctrl.line.strokeWidth) | ||
+ | .style('marker-end', function(node, index) { return 'url(#arrow0)'; }); | ||
+ | |||
+ | // Add the nodes (circles) | ||
+ | Ctrl.svg | ||
+ | .selectAll('.node') | ||
+ | .data(Ctrl.json.nodes) | ||
+ | .enter() | ||
+ | .append('g') | ||
+ | .attr('class', 'node') | ||
+ | .on('dblclick', gotoPage) | ||
+ | .append('circle') | ||
+ | .attr('r', Ctrl.circle.radius) | ||
+ | .style('fill', function(node, index) { return (node.level < 100) ? Ctrl.color[node.level] : Ctrl.tocColor; }) | ||
+ | .call(Ctrl.force.drag); | ||
+ | |||
+ | // Add a title to each node | ||
+ | Ctrl.svg | ||
+ | .selectAll('circle') | ||
+ | .append('title') | ||
+ | .text(function(node, index) { | ||
+ | var title = node.name.replace(/_/g, ' ').toTitleCase(); | ||
+ | if (node.level == 0) { title += ' (this page)'; } | ||
+ | return title; | ||
+ | }); | ||
+ | |||
+ | // Add the level number to each node | ||
+ | Ctrl.svg | ||
+ | .selectAll('.node') | ||
+ | .append('text') | ||
+ | .attr('dx', Ctrl.circle.textXOffset) | ||
+ | .attr('dy', Ctrl.circle.textYOffset) | ||
+ | .text(function(node, index) { return (node.level < 100) ? node.level : ''; }) | ||
+ | .style('font-size', Ctrl.circle.fontSize) | ||
+ | .style('fill', Ctrl.circle.fontColor) | ||
+ | .style('font-weight', Ctrl.circle.fontWeight) | ||
+ | .append('title') | ||
+ | .text(function(node, index) { | ||
+ | var title = node.name.replace(/_/g, ' ').toTitleCase(); | ||
+ | if (node.level == 0) { title += ' (this page)'; } | ||
+ | return title; | ||
+ | }); | ||
+ | |||
+ | // Draw a border around the graph | ||
+ | Ctrl.svg.append('rect') | ||
+ | .attr('x', 0) | ||
+ | .attr('y', 0) | ||
+ | .attr('height', Ctrl.canvas.height - 1) | ||
+ | .attr('width', Ctrl.canvas.width - 1) | ||
+ | .style('stroke', Ctrl.canvas.borderColor) | ||
+ | .style('fill', 'none') | ||
+ | .style('stroke-width', Ctrl.canvas.borderWidth); | ||
− | + | // Set up the tick handler | |
+ | Ctrl.force.on('tick', function() { | ||
Ctrl.svg | Ctrl.svg | ||
− | .selectAll(' | + | .selectAll('.link') |
− | . | + | .attr('x1', function(link) { return inBounds(link.source.x); }) |
− | . | + | .attr('y1', function(link) { return inBounds(link.source.y); }) |
+ | .attr('x2', function(link) { return inBounds(link.target.x); }) | ||
+ | .attr('y2', function(link) { return inBounds(link.target.y); }); | ||
− | |||
Ctrl.svg | Ctrl.svg | ||
.selectAll('.node') | .selectAll('.node') | ||
− | + | .attr('transform', function(node) { | |
− | + | return 'translate(' + inBounds(node.x) + ',' + inBounds(node.y) + ')'; | |
− | .attr(' | + | }); |
− | + | }); | |
− | |||
− | |||
− | |||
− | + | // Start the force simulation | |
− | + | Ctrl.force.start(); | |
+ | } | ||
+ | } | ||
− | + | function main() { | |
− | + | $(window).resize(function() { | |
− | + | renderGraph(); | |
− | |||
− | |||
}); | }); | ||
+ | |||
+ | loadData(renderGraph); | ||
} | } | ||
$(document).ready(function() { main(); }); | $(document).ready(function() { main(); }); |
Latest revision as of 14:06, 23 September 2019
/* Name: MediaWiki:Network-graph.js Purpose: Build the network graph for the page being displayed Parameters: title = the internal title of the reference (e.g., Cerebral_hemispheric_lateralization_in_cardiac_autonomic_control) depth = the number of levels to include in the graph (1 - 9) Author: Alan O'Neill Release Date: September 23, 2019 */ var Json; // populated once when the page loads var Ctrl; // rebuilt each time the graph is rendered on a given page (e.g., when resizing) function init() { Ctrl = { json: {}, canvas: { width: $('#network-graph').width(), height: $('#network-graph').width(), borderColor: 'black', borderWidth: '1px' }, color: [ '#e6194B', // red '#f58231', // orange '#ffe119', // yellow '#3cb44b', // green '#42d4f4', // cyan '#4363d8', // blue '#000075', // navy '#f032e6', // magenta '#800000', // maroon '#9a6324', // brown ], tocColor: '#48a5d1', circle: { radius: 14, textXOffset: -4, textYOffset: 4, fontSize: '12px', fontColor: 'white', fontWeight: 'bold', }, arrow: { size: 6, strokeWidth: 1, // 0 = no arrow color: 'grey', }, line: { strokeWidth: 2, color: 'grey', }, physical: { charge: -10, linkDistance: 10, distance: 120, gravity: .02, }, force: null, svg: null, } $.extend(Ctrl.json, Json); } String.prototype.toTitleCase = function() { return this.replace( /\w\S*/g, function(txt) { return txt.charAt(0).toUpperCase() + txt.substr(1).toLowerCase(); } ); } function inBounds(n) { // Adjust a coordinate so it is within the bounds of the canvas (remember, it's always square) // n - the number to adjust // Return the adjusted value var min = Ctrl.circle.radius * 3; var max = Ctrl.canvas.width - min; return Math.min(Math.max(n, min), max); } function getQuery(name) { // Return the value of a parameter in the query string // name - the name in the name/value pair var value = ''; var query = window.location.search.substring(1).split('&'); for (var i = 0; i < query.length; i++) { var nv = query[i].split('='); if (nv[0] == name) { value = nv[1]; break; } } return value; } function getBaseUrl() { // Return the base URL for the site (e.g., 'https://sudepwiki.pathology.jhmi.edu') var baseUrl = location.protocol + '//' + location.host + location.pathname; var i = baseUrl.indexOf('/index.php'); if (i < 0) { i = baseUrl.indexOf('/site-links'); } baseUrl = baseUrl.substr(0, i); return baseUrl; } function getTitle() { // Return the title of the article from the URL var title = getQuery('title'); if (title.length == 0) { var delim = '/index.php/'; title = location.pathname.substr(location.pathname.indexOf(delim) + delim.length); // e.g., Cerebral_hemispheric_lateralization_in_cardiac_autonomic_control } title = decodeURIComponent(title); return title; } function getDepth() { // Return the depth to use for the graph (?) var depth = 9; if (depth < 1) { depth = 1; } else if (depth > 9) { depth = 9; } return depth; } function gotoPage(node, index) { // Go to the page associated with a node // node - the node that was double clicked (object) // index - the index of the node that was double clicked // When node.level is zero, the node refers to the page being displayed if (node.level > 0) { window.location.href = getBaseUrl() + '/index.php/' + node.name; } else { alert("You're already viewing this page."); } }; function loadData(onSuccess) { $('#network-graph').html('Retrieving data for the network graph...'); $.ajax({ method: 'get', url: getBaseUrl() + '/site-links/network-graph.php', data: { depth: getDepth(), title: getTitle() }, dataType: 'json', success: function(data, textStatus, jqXHR) { if (data == 0) { $('#network-graph').html('No data is available for this graph.'); } else { Json = data; onSuccess(); } }, error: function(jqXHR, textStatus, errorThrown) { $('#network-graph').html('An error occurred while retrieving data for this graph.'); console.log('loadData: jqXHR =', jqXHR, '; textStatus =', textStatus, '; errorThrown =', errorThrown); }, }); } function renderGraph() { if (typeof Json !== 'undefined') { $('#network-graph').html('Hover over a node to see the title of the article. Double-click on a node to view the article.<br />'); init(); // Adjust the canvas size based on the number of nodes var nodes = Json.nodes.length; if (nodes > 200) { Ctrl.circle.radius *= 2 / 3; } var max = Ctrl.canvas.width; var min = Math.sqrt(nodes) * Ctrl.circle.radius * 10; if (min < max) { Ctrl.canvas.width = Ctrl.canvas.height = min; } // Create the canvas Ctrl.svg = d3.select('#network-graph') .append('svg') .attr('width', Ctrl.canvas.width) .attr('height', Ctrl.canvas.height); // Define the arrow head var depth = getDepth(); for (var level = 0; level < depth; level++ ) { let fullSize = Ctrl.arrow.size; let halfSize = Math.floor(fullSize / 2); Ctrl.svg .append('defs') .selectAll('marker') .data(['arrow' + level]) .enter() .append('marker') .attr('id', function(node, index) { return node; }) // e.g., 'arrow0' .attr('viewBox', '0 -' + halfSize + ' ' + fullSize + ' ' + fullSize) .attr('refX', function(node, index) { return Math.floor(Ctrl.circle.radius / 2) + Ctrl.arrow.size; }) .attr('refY', 0) .attr('markerWidth', fullSize) .attr('markerHeight', fullSize) .attr('orient', 'auto') .append('path') .attr('d', function(node, index) { return 'M0,-' + halfSize + 'L' + fullSize + ',0L0,' + halfSize + 'L' + fullSize + ',0L0,-' + halfSize; }) .style('stroke', Ctrl.arrow.color) .style('stroke-width', Ctrl.arrow.strokeWidth) } // Define the characteristics of the force simulation Ctrl.force = d3.layout .force() .size([Ctrl.canvas.width, Ctrl.canvas.height]) .charge(Ctrl.physical.charge) .linkDistance(Ctrl.physical.linkDistance) .distance(Ctrl.physical.distance) .gravity(Ctrl.physical.gravity); // Introduce the data to the force simulation Ctrl.force .nodes(Ctrl.json.nodes) .links(Ctrl.json.links); // Add the links (lines and arrows) Ctrl.svg .selectAll('.link') .data(Ctrl.json.links) .enter() .append('line') .attr('class', 'link') .style('stroke', Ctrl.line.color) .style('stroke-width', Ctrl.line.strokeWidth) .style('marker-end', function(node, index) { return 'url(#arrow0)'; }); // Add the nodes (circles) Ctrl.svg .selectAll('.node') .data(Ctrl.json.nodes) .enter() .append('g') .attr('class', 'node') .on('dblclick', gotoPage) .append('circle') .attr('r', Ctrl.circle.radius) .style('fill', function(node, index) { return (node.level < 100) ? Ctrl.color[node.level] : Ctrl.tocColor; }) .call(Ctrl.force.drag); // Add a title to each node Ctrl.svg .selectAll('circle') .append('title') .text(function(node, index) { var title = node.name.replace(/_/g, ' ').toTitleCase(); if (node.level == 0) { title += ' (this page)'; } return title; }); // Add the level number to each node Ctrl.svg .selectAll('.node') .append('text') .attr('dx', Ctrl.circle.textXOffset) .attr('dy', Ctrl.circle.textYOffset) .text(function(node, index) { return (node.level < 100) ? node.level : ''; }) .style('font-size', Ctrl.circle.fontSize) .style('fill', Ctrl.circle.fontColor) .style('font-weight', Ctrl.circle.fontWeight) .append('title') .text(function(node, index) { var title = node.name.replace(/_/g, ' ').toTitleCase(); if (node.level == 0) { title += ' (this page)'; } return title; }); // Draw a border around the graph Ctrl.svg.append('rect') .attr('x', 0) .attr('y', 0) .attr('height', Ctrl.canvas.height - 1) .attr('width', Ctrl.canvas.width - 1) .style('stroke', Ctrl.canvas.borderColor) .style('fill', 'none') .style('stroke-width', Ctrl.canvas.borderWidth); // Set up the tick handler Ctrl.force.on('tick', function() { Ctrl.svg .selectAll('.link') .attr('x1', function(link) { return inBounds(link.source.x); }) .attr('y1', function(link) { return inBounds(link.source.y); }) .attr('x2', function(link) { return inBounds(link.target.x); }) .attr('y2', function(link) { return inBounds(link.target.y); }); Ctrl.svg .selectAll('.node') .attr('transform', function(node) { return 'translate(' + inBounds(node.x) + ',' + inBounds(node.y) + ')'; }); }); // Start the force simulation Ctrl.force.start(); } } function main() { $(window).resize(function() { renderGraph(); }); loadData(renderGraph); } $(document).ready(function() { main(); });