Published
Edited
May 2, 2020
3 forks
4 stars
Insert cell
Insert cell
Insert cell
{
const svg = makeSvg()
svg.append('text')
.attr('class', 'cluster-info')
.attr('x', 10)
.attr('y', height - 18)
.text('Click a cluster to see the information')
const nodes = getNodes(data)
const subnodesList = data.second_layer.map(subdata => getNodes(subdata))
function render () {
svg.selectAll('ellipse.cluster')
.data(nodes)
.join('ellipse')
.attr('class', 'cluster')
.attr('cx', d => xScale(d.x))
.attr('cy', d => yScale(d.y))
.attr('rx', d => rxScale(d.size))
.attr('ry', d => ryScale(d.size))
.on('click', (d, i) => {
const r = rxScale(d.size)
const area = 2 * Math.PI * r * r
d3.selectAll('text.cluster-info')
.text(`Cluster[${i + 1}]: size=${d.size.toFixed(5)}, r=${r.toFixed(1)}, area=${area.toFixed(1)}, # of papers=${d.num_papers}`)
})
subnodesList.forEach((subnodes, i) => {
const subclusterId = `subcluster-${i}`
const parent = nodes[i]
const parentX = xScale(parent.x)
const parentY = yScale(parent.y)
const parentRx = rxScale(parent.size)
const parentRy = ryScale(parent.size)
const subxScale = d3.scaleLinear()
.domain([-0.5, 0.5])
.range([-parentRx + parentX, parentRx + parentX])
const subyScale = d3.scaleLinear()
.domain([-0.5, 0.5])
.range([parentRy + parentY, -parentRy + parentY])
const subrxScale = d3.scaleLinear()
.domain([0.0, 1.0])
.range([0, 2 * parentRx])
const subryScale = d3.scaleLinear()
.domain([0.0, 1.0])
.range([0, 2 * parentRy])
svg.selectAll(`ellipse.subcluster.${subclusterId}`)
.data(subnodes)
.join('ellipse')
.attr('class', `subcluster ${subclusterId}`)
.attr('cx', d => subxScale(d.x))
.attr('cy', d => subyScale(d.y))
.attr('rx', d => subrxScale(d.size))
.attr('ry', d => subryScale(d.size))
.on('click', (d, j) => {
const r = subrxScale(d.size)
const area = 2 * Math.PI * r * r
d3.selectAll('text.cluster-info')
.text(`Subcluster[${i + 1} \u2192 ${j + 1}]: size=${d.size.toFixed(5)}, r=${r.toFixed(1)}, area=${area.toFixed(0)}, # of papers=${d.num_papers}`)
})
// renders paper distribution
})
}
render()
setTimeout(
() => {
const force = initializeForce(nodes)
subnodesList.forEach(subnodes => initializeForce(subnodes))
force.on('tick', () => {
render()
})
},
1000
)
return svg.node()
}
Insert cell
md`## Utilities`
Insert cell
d3 = require('d3@v5')
Insert cell
width = 600
Insert cell
height = 600
Insert cell
xScale = d3.scaleLinear()
.domain([-0.5, 0.5])
.range([0, width])
Insert cell
yScale = d3.scaleLinear()
.domain([-0.5, 0.5])
.range([height, 0])
Insert cell
rxScale = d3.scaleLinear()
.domain([0.0, 1.0])
.range([0, width])
Insert cell
ryScale = d3.scaleLinear()
.domain([0.0, 1.0])
.range([0, height])
Insert cell
function getNodes (data) {
const numPapers = data.papers.reduce(
(sum, paper) => {
return sum + paper.num_papers;
},
0
)
return data.x.map((x, i) => {
return {
x: x,
y: data.y[i],
size: 0.25 * Math.sqrt(data.papers[i].num_papers / numPapers),
topic: data.topic[i],
num_papers: data.papers[i].num_papers
}
})
}
Insert cell
Insert cell
data = loadData()
Insert cell
function makeSvg () {
const svg = d3.select(DOM.svg())
.attr('width', width)
.attr('height', height)
return svg
}
Insert cell
function initializeForce (nodes) {
const collide = d3.forceCollide()
.radius(n => n.size)
const center = forceBoundingBoxCenter()
const centerX = d3.forceX()
.x(0)
const centerY = d3.forceY()
.y(0)
const links = calculateNodeLinks(nodes)
const forceLink = d3.forceLink(links)
.distance(link => link.distance)
.strength(() => 0.5)
return d3.forceSimulation(nodes)
.force('collide', collide)
.force('center', center)
.force('centerX', centerX)
.force('centerY', centerY)
.force('link', forceLink)
}
Insert cell
// Force that moves the center of the bounding box to (0, 0).
function forceBoundingBoxCenter () {
let _nodes

function force () {
const minX = d3.min(_nodes.map(node => node.x - node.size))
const maxX = d3.max(_nodes.map(node => node.x + node.size))
const minY = d3.min(_nodes.map(node => node.y - node.size))
const maxY = d3.max(_nodes.map(node => node.y + node.size))
const cX = minX + (0.5 * (maxX - minX))
const cY = minY + (0.5 * (maxY - minY))
_nodes.forEach(node => {
node.x -= cX
node.y -= cY
})
}

force.initialize = function (nodes) {
_nodes = nodes
}

return force
}
Insert cell
function getPaperNodes (papers) {
const radius = 0.01
const angleSpeed = (2.0 * Math.PI) / papers.prob.length
return papers.prob.map((prob, i) => {
const angle = i * angleSpeed
const distance = Math.pow(1.0 - prob, 2)
return {
prob: prob,
x: distance * Math.cos(angle),
y: distance * Math.sin(angle),
r: radius
}
})
}
Insert cell
function initializePaperDistributionForce (nodes) {
const collide = d3.forceCollide()
.radius(d => d.r)
.iterations(15)
const centerX = d3.forceX(0)
.strength(0.05)
const centerY = d3.forceY(0)
.strength(0.05)
const force = d3.forceSimulation(nodes)
.alphaMin(0.001)
.alphaDecay(0.0339) // 200 updates
.force('collide', collide)
.force('centerX', centerX)
.force('centerY', centerY)
return force
}
Insert cell
html`
<style>
ellipse.cluster {
stroke: black;
stroke-width: 1.0;
stroke-dasharray: 1 1;
fill: white;
fill-opacity: 0.0;
}
ellipse.subcluster {
stroke: black;
stroke-width: 1.0;
fill: white;
fill-opacity: 0.0;
}

ellipse.paper-dot {
stroke-width: 1.0;
stroke: none;
}

line.grid-line {
stroke-width: 0.5;
stroke: black;
stroke-dasharray: 1 2;
}

rect.grid-fill {
stroke: none;
fill-opacity: 1.0;
}

path.density-contour {
stroke-width: 1.0;
stroke: black;
fill: none;
}
</style>
`
Insert cell
md`## Data`
Insert cell
function loadData () {
return FileAttachment('clusters.json').json()
}
Insert cell
Insert cell
Insert cell
Insert cell
Insert cell
{
const clusterIndex = 9
const svg = makeSvg()
const clusterPane = svg.append('g')
.attr('class', 'cluster-pane')
const contourPane = svg.append('g')
.attr('class', 'contour-pane')
const gridPane = svg.append('g')
.attr('class', 'grid-pane')
const numGridRows = 80
const numGridColumns = 80
// renders grids
for (let r = 0; r <= numGridRows; ++r) {
const y = r * (height / numGridRows)
gridPane.append('line')
.attr('class', 'grid-line')
.attr('x1', 0)
.attr('x2', width)
.attr('y1', y)
.attr('y2', y)
}
for (let c = 0; c <= numGridColumns; ++c) {
const x = c * (width / numGridRows)
gridPane.append('line')
.attr('class', 'grid-line')
.attr('x1', x)
.attr('x2', x)
.attr('y1', 0)
.attr('y2', height)
}
gridPane.append('text')
.attr('class', 'message')
.attr('x', 10)
.attr('y', height - 18)
.text('simulation: running')
const subcluster = data.second_layer[clusterIndex]
const clusterNodes = getNodes(subcluster)
const paperNodesList = subcluster.papers.map(getPaperNodes)

// rendering
const xScale = d3.scaleLinear()
.domain([-0.5, 0.5])
.range([0, width])
const yScale = d3.scaleLinear()
.domain([-0.5, 0.5])
.range([height, 0])
const rxScale = d3.scaleLinear()
.domain([0.0, 1.0])
.range([0, width])
const ryScale = d3.scaleLinear()
.domain([0.0, 1.0])
.range([0, height])
function renderClusters () {
clusterPane.selectAll('ellipse.cluster')
.data(clusterNodes)
.join('ellipse')
.attr('class', 'cluster')
.attr('cx', d => xScale(d.x))
.attr('cy', d => yScale(d.y))
.attr('rx', d => rxScale(d.size))
.attr('ry', d => ryScale(d.size))
// render paper distributions
clusterNodes.forEach((clusterNode, i) => {
renderPapers(clusterNode, paperNodesList[i], `cluster-${i}`)
})
}
function makePaperScales (clusterNode, paperNodes) {
const clusterX = xScale(clusterNode.x)
const clusterY = yScale(clusterNode.y)
const clusterRx = rxScale(clusterNode.size) * 0.8
const clusterRy = ryScale(clusterNode.size) * 0.8
const minPaperX = d3.min(paperNodes.map(paperNode => paperNode.x - paperNode.r))
const maxPaperX = d3.max(paperNodes.map(paperNode => paperNode.x + paperNode.r))
const minPaperY = d3.min(paperNodes.map(paperNode => paperNode.y - paperNode.r))
const maxPaperY = d3.max(paperNodes.map(paperNode => paperNode.y + paperNode.r))
return {
paperXScale: d3.scaleLinear()
.domain([minPaperX, maxPaperX])
.range([clusterX - clusterRx, clusterX + clusterRx]),
paperYScale: d3.scaleLinear()
.domain([minPaperY, maxPaperY])
.range([clusterY + clusterRy, clusterY - clusterRy]),
paperRxScale: d3.scaleLinear()
.domain([0.0, maxPaperX - minPaperX])
.range([0, clusterRx * 2]),
paperRyScale: d3.scaleLinear()
.domain([0.0, maxPaperY - minPaperY])
.range([0, clusterRy * 2])
}
}

function renderPapers (clusterNode, paperNodes, clusterId) {
const {
paperXScale,
paperYScale,
paperRxScale,
paperRyScale
} = makePaperScales(clusterNode, paperNodes)
clusterPane.selectAll(`ellipse.paper-dot.${clusterId}`)
.data(paperNodes)
.join('ellipse')
.attr('class', `paper-dot ${clusterId}`)
.attr('cx', d => paperXScale(d.x))
.attr('cy', d => paperYScale(d.y))
.attr('rx', d => paperRxScale(d.r))
.attr('ry', d => paperRyScale(d.r))
.attr('fill', d => d3.interpolateTurbo(d.prob))
}
function calculateGridDensity () {
const grids = new Array(numGridRows * numGridColumns)
for (let r = 0; r < numGridRows; ++r) {
for (let c = 0; c < numGridColumns; ++c) {
grids[c + (r * numGridColumns)] = {
count: 0,
totalDensity: 0,
density () {
return (this.count > 0) ? (this.totalDensity / this.count) : 0
}
}
}
}
clusterNodes.forEach((clusterNode, i) => {
const paperNodes = paperNodesList[i]
const {
paperXScale,
paperYScale
} = makePaperScales(clusterNode, paperNodes)
paperNodes.forEach(paperNode => {
const x = paperXScale(paperNode.x)
const y = paperYScale(paperNode.y)
const c = Math.floor(numGridColumns * (x / width))
const r = Math.floor(numGridRows * (y / height))
const grid = grids[c + (r * numGridColumns)]
++grid.count
grid.totalDensity += paperNode.prob
})
})
return grids
}
function renderContours (grids) {
const geoPath = d3.geoPath()
.projection(d3.geoTransform({
point (x, y) {
this.stream.point(x * (width / numGridColumns), y * (height / numGridRows))
}
}))
const contours = d3.contours()
.size([numGridColumns, numGridRows])
const densities = grids.map(grid => grid.density())
const thresholds = Array.from({ length: 10 }).map((_, i) => i / 10.0)
thresholds.forEach(threshold => {
const contour = contours.contour(densities, threshold)
contourPane.append('path')
.attr('class', 'density-contour')
.attr('d', geoPath(contour))
})
}

// runs the force simulation and renders
Promises.delay(1000)
.then(() => {
const force = initializeForce(clusterNodes)
paperNodesList.forEach(initializePaperDistributionForce)
force
.on('tick', renderClusters)
.on('end', () => {
gridPane.select('text.message')
.text('simulation: done')
const grids = calculateGridDensity()
renderContours(grids)
})
})
renderClusters()

return svg.node()
}
Insert cell

Purpose-built for displays of data

Observable is your go-to platform for exploring data and creating expressive data visualizations. Use reactive JavaScript notebooks for prototyping and a collaborative canvas for visual data exploration and dashboard creation.
Learn more