diff --git a/src/App.vue b/src/App.vue
index 93774d4..ef4498a 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -3,6 +3,7 @@
Upload CSV or JSON
+ {{ nodeCount }} nodes, {{ edgeCount }} edges
+
{{ layoutRunning ? 'Stop' : 'Start' }} layout
-
-
-
+
+
+
+
+
+
Download JSON
@@ -92,6 +123,7 @@
import geo from 'geojs/geo.js';
import LayoutWorker from 'worker-loader!./worker.js';
+import * as scales from './scales.js';
const layoutWorker = new LayoutWorker();
let map;
@@ -104,20 +136,27 @@ let nodeMap;
export default {
data: function () {
return {
+ fields: [],
showEdges: false,
edgeOpacity: 0.5,
- radius: 2,
+ size: 0.5,
+ sizeField: 'degree',
layoutRunning: false,
alpha: 1.0,
alphaFromWorker: false,
- charge: true,
- chargeStrength: -30,
+ chargeStrength: 30,
theta: 1.5,
- collide: true,
collideStrength: 0.7,
- link: true,
linkStrength: 1,
center: true,
+ xField: null,
+ xStrength: 0,
+ yField: null,
+ yStrength: 0,
+ radialField: null,
+ radialStrength: 0,
+ nodeCount: 0,
+ edgeCount: 0,
};
},
mounted() {
@@ -163,6 +202,19 @@ export default {
layoutWorker.onmessage = e => {
if (e.data.type === 'graph') {
graph = e.data.graph;
+ this.nodeCount = graph.nodes.length;
+ this.edgeCount = graph.edges.length;
+
+ const ignoreFields = ['x', 'y', 'vx', 'vy'];
+ this.fields = [];
+ graph.nodes.forEach(n => {
+ Object.keys(n).forEach(f => {
+ if (!ignoreFields.includes(f) && !this.fields.includes(f)) {
+ this.fields.push(f);
+ }
+ });
+ });
+ this.fields.sort();
map.deleteLayer(layer);
layer = map.createLayer('feature', {features: ['point', 'line']});
@@ -185,11 +237,12 @@ export default {
fillColor: 'grey',
fillOpacity: 0.5,
strokeOpacity: 0.5,
- radius: nodeid => Math.max(1, Math.pow(2, map.zoom()) * Math.sqrt(graph.nodes[nodeid].degree) * this.radius)
},
position: nodeid => graph.nodes[nodeid]
}).data(Object.keys(graph.nodes));
+ this.updateSizeScale();
+
map.geoOn(geo.event.zoom, () => {
// Ensure selection quadtree updates with new point sizes
points.dataTime().modified();
@@ -198,14 +251,18 @@ export default {
map.draw();
points
- .geoOn(geo.event.feature.mouseon, function (evt) {
- const nodeid = evt.data, node = graph.nodes[nodeid];
- let text = node.id;
- if (text) {
- tooltip.position(evt.mouse.geo);
- tooltipElem.innerText = text;
- }
- tooltipElem.classList.toggle('hidden', !text);
+ .geoOn(geo.event.feature.mouseon, (evt) => {
+ const nodeid = evt.data;
+ const node = graph.nodes[nodeid];
+ tooltip.position(evt.mouse.geo);
+ let description = `
${node.id}
`;
+ this.fields.forEach(key => {
+ if (key !== 'id') {
+ description += `${key}: ${node[key]}
`;
+ }
+ })
+ tooltipElem.innerHTML = description;
+ tooltipElem.classList.toggle('hidden', false);
})
.geoOn(geo.event.feature.mousemove, function (evt) {
tooltip.position(evt.mouse.geo);
@@ -226,16 +283,19 @@ export default {
}
}
- // Add watchers which simply sync data to layout worker
+ // Add watchers which sync data to layout worker
[
- 'charge',
'chargeStrength',
'theta',
- 'collide',
'collideStrength',
- 'link',
'linkStrength',
'center',
+ 'xField',
+ 'xStrength',
+ 'yField',
+ 'yStrength',
+ 'radialField',
+ 'radialStrength',
].forEach(name => {
function sendToWorker(value) {
layoutWorker.postMessage({type: name, value});
@@ -271,11 +331,21 @@ export default {
},
immediate: true,
},
- radius: {
+ size: {
+ handler(value) {
+ layoutWorker.postMessage({type: 'size', value});
+ if (points) {
+ this.updateSizeScale();
+ map.draw();
+ }
+ },
+ immediate: true,
+ },
+ sizeField: {
handler(value) {
- layoutWorker.postMessage({type: 'radius', value});
+ layoutWorker.postMessage({type: 'sizeField', value});
if (points) {
- points.modified();
+ this.updateSizeScale();
map.draw();
}
},
@@ -318,6 +388,14 @@ export default {
}
}
},
+ updateSizeScale() {
+ if (points) {
+ const sizeScale = scales.generateSizeScale(graph.nodes, this.sizeField, this.size);
+ points.style('radius', (nodeid) => {
+ return Math.pow(2, map.zoom()) * sizeScale(graph.nodes[nodeid]);
+ });
+ }
+ },
download() {
const nodesWithPositions = graph.nodes.map((n, i) => ({
...n,
@@ -346,14 +424,13 @@ export default {
overflow: hidden;
}
#tooltip {
- margin-left: 0px;
- margin-top: -20px;
- height: 16px;
+ margin-left: 20px;
+ margin-top: 20px;
line-height: 16px;
padding: 2px 5px;
background: rgba(255, 255, 255, 0.75);
border-radius: 10px;
- border-bottom-left-radius: 0;
+ border-top-left-radius: 0;
border: 1px solid rgba(0, 0, 0, 0.75);
font-size: 12px;
color: black;
diff --git a/src/scales.js b/src/scales.js
new file mode 100644
index 0000000..39e79ff
--- /dev/null
+++ b/src/scales.js
@@ -0,0 +1,23 @@
+let d3 = require('d3/dist/d3.js');
+
+// generateScale()
+// Create a linear scaling function for a numeric data field.
+// Range will go from `min` to `max`, with invalid (non-numeric) values at `invalid`.
+// If `area` is specified, range will be scaled such that every point on average fills `area` square units.
+export function generateScale(arr, field, {area = null, min = -0.5, max = 0.5, invalid = 0.7}) {
+ const size = area ? Math.sqrt(arr.length * area) : 1;
+ const domain = d3.extent(arr, n => n[field]);
+ const scale = d3.scaleLinear().domain(domain).range([size * min, size * max]);
+ return n => {
+ const val = n[field];
+ if (!isNaN(parseFloat(val)) && isFinite(val)) {
+ return scale(val);
+ }
+ return size * invalid;
+ }
+}
+
+export function generateSizeScale(arr, field, size) {
+ const sizeScale = generateScale(arr, field, {min: 3, max: 500*500, invalid: 2});
+ return d => Math.sqrt(sizeScale(d)) * size;
+}
diff --git a/src/worker.js b/src/worker.js
index 126b36a..4d1110a 100644
--- a/src/worker.js
+++ b/src/worker.js
@@ -1,7 +1,12 @@
let d3 = require('d3/dist/d3.js');
+let scales = require('./scales.js');
-let radius = 2;
+let size = 1;
+let sizeField = 'degree';
let linkStrength = 1;
+let xField = 'degree';
+let yField = 'degree';
+let radialField = 'degree';
let linkStrengthFunctions = {
inverseMinDegree: link => linkStrength / Math.min(link.source.degree, link.target.degree),
@@ -10,20 +15,17 @@ let linkStrengthFunctions = {
};
let linkDistanceFunctions = {
- sumSqrtDegree: link => (Math.sqrt(link.source.degree) + Math.sqrt(link.target.degree)) * radius,
+ sumSqrtDegree: link => (Math.sqrt(link.source.degree) + Math.sqrt(link.target.degree)) * size,
};
let link = d3.forceLink().id(d => d.id).distance(linkDistanceFunctions.sumSqrtDegree).strength(linkStrengthFunctions.inverseMinDegree);
let charge = d3.forceManyBody();
-let collide = d3.forceCollide().radius(d => Math.sqrt(d.degree) * radius);
+let collide = d3.forceCollide();
let center = d3.forceCenter();
-// let radial = d3.forceX(d => ((d.discovery ? d.discovery : 2020) - 1900) * 150).strength(1);
+let x = d3.forceX();
+let y = d3.forceY();
+let radial = d3.forceRadial();
let simulation = d3.forceSimulation()
- .force('link', link)
- .force('charge', charge)
- .force('collide', collide)
- .force('center', center)
- // .force('radial', radial)
.alphaMin(0)
.alphaTarget(0)
.stop();
@@ -60,6 +62,14 @@ loadGraph = function(graph) {
postMessage({type: 'graph', graph});
postMessage({type: 'positions', nodes: graph.nodes.map(n => ({x: n.x, y: n.y}))});
+ // Initialize data-dependent scales
+ collide.radius(scales.generateSizeScale(simulation.nodes(), sizeField, size));
+ x.x(scales.generateScale(simulation.nodes(), xField, {area: 1000}));
+ y.y(scales.generateScale(simulation.nodes(), yField, {area: 1000}));
+ radial.radius(scales.generateScale(
+ simulation.nodes(), radialField, {area: 1000, min: 0.5, max: 1.5, invalid: 1.6},
+ ));
+
let oldLink = simulation.force('link');
simulation.force('link', link);
link.links(graph.edges);
@@ -86,32 +96,56 @@ onmessage = function(e) {
else if (e.data.type === 'alpha') {
simulation.alpha(e.data.value);
}
- else if (e.data.type === 'radius') {
- radius = e.data.value;
- link.strength(linkStrengthFunctions.inverseMinDegree);
- collide.radius(d => Math.sqrt(d.degree) * radius);
+ else if (e.data.type === 'size') {
+ size = e.data.value;
+ link.strength(link.strength());
+ collide.radius(scales.generateSizeScale(simulation.nodes(), sizeField, size));
+ }
+ else if (e.data.type === 'sizeField') {
+ sizeField = e.data.value;
+ collide.radius(scales.generateSizeScale(simulation.nodes(), sizeField, size));
}
else if (e.data.type === 'linkStrength') {
+ simulation.force('link', e.data.value ? link : null);
linkStrength = e.data.value;
- link.strength(linkStrengthFunctions.inverseMinDegree);
+ link.strength(link.strength());
}
else if (e.data.type === 'chargeStrength') {
- charge.strength(e.data.value);
+ simulation.force('charge', e.data.value ? charge : null);
+ charge.strength(-e.data.value);
}
else if (e.data.type === 'collideStrength') {
+ simulation.force('collide', e.data.value ? collide : null);
collide.strength(e.data.value);
}
- else if (e.data.type === 'collide') {
- simulation.force('collide', e.data.value ? collide : null);
+ else if (e.data.type === 'center') {
+ simulation.force('center', e.data.value ? center : null);
}
- else if (e.data.type === 'link') {
- simulation.force('link', e.data.value ? link : null);
+ else if (e.data.type === 'xStrength') {
+ simulation.force('x', e.data.value ? x : null);
+ x.strength(e.data.value);
}
- else if (e.data.type === 'charge') {
- simulation.force('charge', e.data.value ? charge : null);
+ else if (e.data.type === 'xField') {
+ xField = e.data.value;
+ x.x(scales.generateScale(simulation.nodes(), xField, {area: 1000}));
}
- else if (e.data.type === 'center') {
- simulation.force('center', e.data.value ? center : null);
+ else if (e.data.type === 'yStrength') {
+ simulation.force('y', e.data.value ? y : null);
+ y.strength(e.data.value);
+ }
+ else if (e.data.type === 'yField') {
+ yField = e.data.value;
+ y.y(scales.generateScale(simulation.nodes(), yField, {area: 1000}));
+ }
+ else if (e.data.type === 'radialStrength') {
+ simulation.force('radial', e.data.value ? radial : null);
+ radial.strength(e.data.value);
+ }
+ else if (e.data.type === 'radialField') {
+ radialField = e.data.value;
+ radial.radius(scales.generateScale(
+ simulation.nodes(), radialField, {area: 1000, min: 0.5, max: 1.5, invalid: 1.6},
+ ));
}
else {
throw Error(`Unknown message type '${e.data.type}'`);