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}'`);