Skip to content

Instantly share code, notes, and snippets.

@vijithassar
Last active October 2, 2019 16:45
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save vijithassar/51a0eea01d26158611b8dc07be91c1aa to your computer and use it in GitHub Desktop.
Save vijithassar/51a0eea01d26158611b8dc07be91c1aa to your computer and use it in GitHub Desktop.
Selective Force Positioning

Overview

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.

Implementation

Setup

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'

Configuration

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

DOM

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)
    }

Clusters

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)]

Points

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)

Force Positioning

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
    }

Render

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)

Marker

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.

    }

Polygon

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
    }

Mouseover

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)
        })
    }

Alternate Positioning

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)
    }

Execution

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()

Finish

Close and execute the anonymous function wrapper, passing in the global D3 object.

})(d3)
<html>
<head>
<link rel="stylesheet" href="style.css">
</head>
<body>
<div class="wrapper"></div>
<div class="scripts">
<script type="text/javascript" src="https://d3js.org/d3.v4.min.js"></script>
<script type="text/javascript" src="https://unpkg.com/lit-web"></script>
<script type="text/markdown" src="README.md"></script>
</div>
</body>
</html>
svg {
background-color: grey;
}
g.points circle {
stroke: black;
stroke-width: 2px;
fill: white;
}
g.marker circle {
fill-opacity: 0.2;
fill: red;
}
g.marker polygon {
fill-opacity: 0;
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment