Force-directed positioning based on a physics simulation can help with graph readability because it minimizes node occlusion, but it comes at the expense of precision, because both the X axis and the Y axis are compromised in favor of the simulation. As an alternative, we can position the points in some other fashion, then selectively apply force positioning to declutter the layout in specific regions when the user shifts attention toward them, such as with a mouseover.
This project is written in a heavily annotated style called literate programming. The code blocks from this Markdown document are being executed as JavaScript by lit-web.
As usual, begin with an anonymous function which contains all other script logic and prevents variables from polluting the global space.
(function(d3) {
Enable strict mode, because we are civilized.
'use strict'
A set of variables that will be used later to control the behavior of the graphic, mostly related to positioning.
const height = 500
const width = 960
const cluster_count = 50
const point_count = 1000
const range = 50
const point_radius = 10
const marker_radius = 100
const polygon_vertices = 100
Specify the number of loop iterations that should be used to compute force positioning. Force positioning is an expensive computation, so this causes the page to hang initially; perhaps you noticed when you first loaded this? Higher values will result in cleaner positioning but block initial page load for longer. This tradeoff can be minimized by moving force positioning computations into a web worker, but that would complicate this demonstration.
const collision_detection_strength = 500
Some variables may need to be initialized in an outer scope so they can be accessed across different functions.
let marker
Set up the desired outer DOM for the SVG graphic into which everything else will render.
const dom = selection => {
const svg = selection.append('svg')
.attr('height', height)
.attr('width', width)
svg
.append('g')
.classed('points', true)
marker = svg
.append('g')
.classed('marker', true)
}
Create a set of clusters with randomized positions. The cluster positions will later be used to help position the individual points.
// calculate a bunch of cluster centers
const clusters = d3.range(cluster_count)
.map(() => {
const cluster = {
x: Math.random() * width,
y: Math.random() * height
}
return cluster
})
// function to select a single random cluster
const cluster = () => clusters[Math.floor(Math.random() * clusters.length)]
Based on the set of available clusters, create a set of points which are spatially grouped. Positions will be randomized, but only within the cluster; this keeps the points close enough to cause the occlusion that we'll later declutter using force positioning.
// given a cluster, generate a point in that cluster
const point = () => {
// select a cluster
const center = cluster()
const position = {
// set aside under a key called default so we
// can add another position later
default: {
x: center.x + Math.random() * range,
y: center.y + Math.random() * range
}
}
return position
}
// generate the desired quantity of clustered points
const points = d3.range(point_count)
.map(point)
Given a set of input points, initialize a force-directed physics simulation and calculate a secondary position for each point which avoids occlusion with all other points.
const collision_detection = points => {
// set up competing forces
const collision = d3.forceCollide().radius(point_radius)
const x = d3.forceX()
.x(d => d.default.x)
const y = d3.forceY()
.y(d => d.default.y)
// create the simulation
const simulation = d3.forceSimulation()
.force('collide', collision)
.force('x', x)
.force('y', y)
// slice to create a copy of the points
.nodes(points.slice())
.stop()
// run the simulation
let count = 0
while (count++ < collision_detection_strength) {
simulation.tick()
}
// clean up the results, most of the fields are unnecessary
const simulated_points = simulation.nodes()
.map(item => {
const point = {
default: {
x: item.default.x,
y: item.default.y
},
alternate: {
x: item.x,
y: item.y
}
}
return point
})
return simulated_points
}
Run the force positioning calculation, and then initially draw points in the default positions, allowing for occlusion.
const render = (selection, points) => {
// run force positioning
const positioned = collision_detection(points)
// render points
selection
.selectAll('circle.point')
.data(positioned)
.enter()
.append('circle')
.classed('point', true)
.attr('r', point_radius)
// position points
.attr('cx', d => d.default.x)
.attr('cy', d => d.default.y)
Create a mouseover indicator which moves with the mouse so the user can more readily see that repositioning is triggered by interactions. Alongside the visible circle indicator, we'll also create an invisible many-sided polygon that closely mirrors the circle; more on this in a moment.
marker
.append('circle', true)
.attr('r', marker_radius)
marker
.append('polygon', true)
.attr('points', vertices().join(' '))
End of the rendering function.
}
Now it's time for a little sleight of hand: the mouseover marker displayed is a circle, but the decision regarding whether to reposition points will be based on a calculation that instead uses an invisible polygon that differs very slightly from the circle at its corners. The difference won't really be noticeable to anybody, but this lets us use the handy polygonContains method provided by d3-polygon.
So: given a centerpoint, we need to determine the coordinates that define the polygon. This is easy enough to reason about in polar coordinates – you just tick your way around the circle, using the same radius and slightly incrementing the angle each time, and return the new position as a vertex for the polygon. However, we'll also then need to convert back to cartesian space, because that's how SVG coordinates work.
// center will default to the center of the viewport unless
// we specify otherwise
const vertices = (center = [width * 0.5, height * 0.5]) => {
// create a bunch of points
const points = d3.range(polygon_vertices)
.map(index => {
const angle = 2 * Math.PI / polygon_vertices * index
const x = marker_radius * Math.cos(angle)
const y = marker_radius * Math.sin(angle)
return [x, y]
})
// deform each point by the current input position
.map(item => {
return [
item[0] + center[0],
item[1] + center[1]
]
})
return points
}
We need to update the position of the visible and invisible marker shapes on every mouse movement so they'll move around and stay synchronized with the user's mouse movements.
We'll then pass the current mouse position to a position_points()
function that will move the rendered points around based on whether they fall within the boundaries of the polygon.
const track = () => {
d3.select('svg')
.on('mousemove', function() {
const position = d3.mouse(this)
// reposition the circle
marker
.select('circle')
.attr('cx', position[0])
.attr('cy', position[1])
// change the vertices of the polygon
const polygon = vertices(position)
marker
.select('polygon')
.attr('points', polygon.join(' '))
position_points(polygon)
})
}
Once we have the polygon, quickly curry it into a reusable function that tests an individual datum using d3.polygonContains()
. For every point, run that test function and animate into the new position if necessary.
Because each point is traveling a unique distance, a straightforward linear easing function tends to look better than the default, since it seems to synchronize the motion of the points.
const position_points = polygon => {
// test whether a point is inside the input polygon
const in_polygon = d => {
// d3.polygonContains expects points to be an array
// of coordinates in format [x, y], so coerce the more
// descriptive object structure into that
const point_default = [d.default.x, d.default.y]
return d3.polygonContains(polygon, point_default)
}
// position points
d3.select('g.points')
.selectAll('circle')
.transition()
.ease(d3.easeLinear)
.attr('cx', d => in_polygon(d) ? d.alternate.x : d.default.x)
.attr('cy', d => in_polygon(d) ? d.alternate.y : d.default.y)
}
A simple wrapper to call the above functions in the correct order.
const execute = () => {
d3.select('div.wrapper')
.call(dom)
.select('svg g.points')
.call(render, points)
track()
}
execute()
Close and execute the anonymous function wrapper, passing in the global D3 object.
})(d3)