From 2516d3111f11f3ebd7a5e332ebfdb757589f5afe Mon Sep 17 00:00:00 2001 From: Jeffrey Baumes Date: Thu, 23 Jan 2020 11:41:03 -0500 Subject: [PATCH] Gravity, weights, and a few more vis controls --- src/App.vue | 63 +++++++++++++++++++++++++++++++++++++++------------ src/scales.js | 2 +- src/worker.js | 33 ++++++++++++++++++++------- 3 files changed, 75 insertions(+), 23 deletions(-) diff --git a/src/App.vue b/src/App.vue index 829b1c8..6d51322 100644 --- a/src/App.vue +++ b/src/App.vue @@ -16,6 +16,20 @@ label="Edge opacity" hide-details > + + @@ -110,6 +123,13 @@ label="Radial field" hide-details > + Download JSON @@ -139,11 +159,12 @@ export default { fields: [], showEdges: false, edgeOpacity: 0.5, + nodeOpacity: 0.5, + nodeStrokeWidth: 1.0, size: 0.5, sizeField: 'degree', layoutRunning: false, alpha: 1.0, - alphaFromWorker: false, chargeStrength: 30, theta: 1.5, collideStrength: 0.7, @@ -155,6 +176,7 @@ export default { yStrength: 0, radialField: null, radialStrength: 0, + gravityStrength: 0, nodeCount: 0, edgeCount: 0, }; @@ -240,7 +262,6 @@ export default { graph.nodes.forEach((n, i) => nodeMap[n.id] = i); lines = layer.createFeature('line').data(graph.edges.map(e => [nodeMap[e.source], nodeMap[e.target]])).style({ position: nodeid => graph.nodes[nodeid], - width: 1, strokeColor: 'black', strokeOpacity: this.edgeOpacity, }); @@ -259,13 +280,18 @@ export default { } }); + // TODO: call geoUtils.convertColor + // TODO: look into https://opengeoscience.github.io/geojs/examples/animation/ + // pointFeature.updateStyleFromArray(updateStyles, null, true); + points = layer.createFeature('point', { primitiveShape: 'triangle', style: { strokeColor: 'black', fillColor: nodeid => graph.nodes[nodeid].select ? ['yellow', 'red'][graph.nodes[nodeid].select - 1] : 'grey', - fillOpacity: 0.5, - strokeOpacity: 0.5, + fillOpacity: this.nodeOpacity, + strokeOpacity: this.nodeOpacity, + strokeWidth: this.nodeStrokeWidth, }, position: nodeid => graph.nodes[nodeid] }).data(Object.keys(graph.nodes)); @@ -312,11 +338,6 @@ export default { points.position(nodeid => positions[nodeid]); map.draw(); } - else if (e.data.type === 'alpha') { - this.alphaFromWorker = true; - this.alpha = e.data.value; - this.$nextTick(() => this.alphaFromWorker = false); - } } // Add watchers which sync data to layout worker @@ -332,6 +353,7 @@ export default { 'yStrength', 'radialField', 'radialStrength', + 'gravityStrength', ].forEach(name => { function sendToWorker(value) { layoutWorker.postMessage({type: name, value}); @@ -359,11 +381,24 @@ export default { map.draw(); } }, + nodeOpacity(value) { + if (points) { + points.style('strokeOpacity', value); + points.style('fillOpacity', value); + points.modified(); + map.draw(); + } + }, + nodeStrokeWidth(value) { + if (points) { + points.style('strokeWidth', value); + points.modified(); + map.draw(); + } + }, alpha: { handler(value) { - if (!this.alphaFromWorker) { - layoutWorker.postMessage({type: 'alpha', value: value}); - } + layoutWorker.postMessage({type: 'alpha', value: value}); }, immediate: true, }, @@ -397,7 +432,7 @@ export default { reader.onload = function (evt) { const extension = file.name.split('.').slice(-1)[0]; if (extension === 'json') { - layoutWorker.postMessage({type: 'loadJSON', text: evt.target.result}); + layoutWorker.postMessage({type: 'loadJSON', text: JSON.parse(evt.target.result)}); } else if (extension === 'csv') { layoutWorker.postMessage({type: 'loadEdgeList', text: evt.target.result}); } else { diff --git a/src/scales.js b/src/scales.js index f01bd1e..9602f81 100644 --- a/src/scales.js +++ b/src/scales.js @@ -21,6 +21,6 @@ export function generateSizeScale(arr, field, size) { if (field === 'None') { return () => 250 * size; } - const sizeScale = generateScale(arr, field, {min: 3, max: 500*500, invalid: 2}); + const sizeScale = generateScale(arr, field, {min: 10*10, max: 500*500, invalid: 2}); return d => Math.sqrt(sizeScale(d)) * size; } diff --git a/src/worker.js b/src/worker.js index a19ef5e..b76ce7b 100644 --- a/src/worker.js +++ b/src/worker.js @@ -12,10 +12,14 @@ let linkStrengthFunctions = { inverseMinDegree: link => linkStrength / Math.min(link.source.degree, link.target.degree), inverseSumDegree: link => linkStrength / (link.source.degree + link.target.degree), inverseSumSqrtDegree: link => linkStrength / (Math.sqrt(link.source.degree) + Math.sqrt(link.target.degree)), + constant: () => linkStrength, + radius: link => linkStrength*linkStrength / Math.min(collide.radius()(link.source), collide.radius()(link.target)), }; let linkDistanceFunctions = { sumSqrtDegree: link => (Math.sqrt(link.source.degree) + Math.sqrt(link.target.degree)) * size, + constant: () => size / 20, + radius: link => (2 - linkStrength) * (collide.radius()(link.source) + collide.radius()(link.target)), }; let link = d3.forceLink().id(d => d.id).distance(linkDistanceFunctions.sumSqrtDegree).strength(linkStrengthFunctions.inverseMinDegree); @@ -25,33 +29,38 @@ let center = d3.forceCenter(); let x = d3.forceX(); let y = d3.forceY(); let radial = d3.forceRadial(); +let gravity = d3.forceRadial().radius(0); let simulation = d3.forceSimulation() .alphaMin(0) .alphaTarget(0) + .alphaDecay(0) .stop(); loadGraph = function(graph) { function tick() { - postMessage({type: 'alpha', value: simulation.alpha()}); postMessage({type: 'positions', nodes: graph.nodes.map(n => ({x: n.x, y: n.y}))}); } if (!graph.nodes) { graph.nodes = d3.set([...graph.edges.map(d => d.source), ...graph.edges.map(d => d.target)]).values().map(d => ({ id: d, - degree: 0, - x: Math.random()*1000, - y: Math.random()*1000, })); } const nodeMap = {}; graph.nodes.forEach(d => { + d.degree = 0; + d.id = '' + d.id; nodeMap[d.id] = d; }); + graph.edges.forEach(d => { + d.source = '' + d.source; + d.target = '' + d.target; + }); graph.edges = graph.edges.filter(e => nodeMap[e.source] && nodeMap[e.target]); graph.edges.forEach(d => { - nodeMap[d.source].degree += 1; - nodeMap[d.target].degree += 1; + const weight = d.weight !== undefined ? +d.weight : 1 + nodeMap[d.source].degree += weight; + nodeMap[d.target].degree += weight; }); graph.nodes.sort((a, b) => d3.ascending(a.degree, b.degree)); @@ -88,7 +97,7 @@ onmessage = function(e) { loadGraph({edges: d3.csvParse(e.data.text)}); } else if (e.data.type === 'loadJSON') { - loadGraph(JSON.parse(e.data.text)); + loadGraph(e.data.text); } else if (e.data.type === 'theta') { charge.theta(e.data.value); @@ -98,16 +107,20 @@ onmessage = function(e) { } else if (e.data.type === 'size') { size = e.data.value; - link.strength(link.strength()); collide.radius(scales.generateSizeScale(simulation.nodes(), sizeField, size)); + link.distance(link.distance()); + link.strength(link.strength()); } else if (e.data.type === 'sizeField') { sizeField = e.data.value; collide.radius(scales.generateSizeScale(simulation.nodes(), sizeField, size)); + link.distance(link.distance()); + link.strength(link.strength()); } else if (e.data.type === 'linkStrength') { simulation.force('link', e.data.value ? link : null); linkStrength = e.data.value; + link.distance(link.distance()); link.strength(link.strength()); } else if (e.data.type === 'chargeStrength') { @@ -147,6 +160,10 @@ onmessage = function(e) { simulation.nodes(), radialField, {area: 1000, min: 0.5, max: 1.5, invalid: 1.6}, )); } + else if (e.data.type === 'gravityStrength') { + simulation.force('gravity', e.data.value ? gravity : null); + gravity.strength(e.data.value); + } else { throw Error(`Unknown message type '${e.data.type}'`); }