d3-sankey-diagram
Version:
Sankey diagram d3 plugin
425 lines (368 loc) • 11.4 kB
JavaScript
import sankey from '../src/sankey.js'
import tape from 'tape'
import { assertAlmostEqual } from './assert-almost-equal.js'
tape('sankey: scale', test => {
const {graph, ordering} = example4to1()
const pos = sankey().ordering(ordering)
test.equal(pos.scale(), null, 'initially scale is null')
pos(graph)
test.equal(pos.scale(), 1 / 20 * 0.5, 'default scaling with 50% whitespace')
pos.whitespace(0).scale(null)(graph)
test.equal(pos.scale(), 1 / 20 * 1.0, 'scaling with 0% whitespace')
test.end()
})
tape('sankey() sets node.rank and node.depth', test => {
//
// .-------.
// 0----1----2
// `-,__`:::3,
// 4`
//
const graph = {
nodes: [
{id: '0'},
{id: '1'},
{id: '2'},
{id: '3'},
{id: '4', direction: 'l'}
],
links: [
{source: '0', target: '1', value: 5},
{source: '0', target: '3', value: 5},
{source: '1', target: '2', value: 5},
{source: '1', target: '3', value: 1},
{source: '0', target: '2', value: 1},
{source: '3', target: '4', value: 1}
]
}
sankey()(graph)
test.deepEqual(nodeAttr(graph, d => d.rank), [0, 1, 2, 2, 2], 'node ranks')
test.deepEqual(nodeAttr(graph, d => d.depth), [0, 1, 0, 1, 2], 'node depths')
test.end()
})
tape('sankey() sets node.{x0, y0, x1, y1}', test => {
const {graph, ordering} = example4to1()
// 50% whitespace: scale = 8 / 20 * 0.5 = 0.2
// margin = 8 * 50% / 5 = 0.8
// total node height = 4 * 5 * 0.2 = 4
// remaining space = 8 - 4 - 2*0.8 =2.4
// spread betweeen 3 gaps = 0.8
sankey().size([3, 8]).ordering(ordering)(graph)
test.deepEqual(nodeAttr(graph, d => d.y1 - d.y0), [1, 1, 1, 1, 4], 'node heights')
assertAlmostEqual(test, nodeAttr(graph, d => d.y0), [
0.8,
0.8 + 1 + 0.8,
0.8 + 1 + 0.8 + 1 + 0.8,
0.8 + 1 + 0.8 + 1 + 0.8 + 1 + 0.8,
2 // centred
], 1e-6, 'node y')
assertAlmostEqual(test, nodeAttr(graph, d => d.x0), [0, 0, 0, 0, 2], 'node x')
assertAlmostEqual(test, nodeAttr(graph, d => d.x1), [1, 1, 1, 1, 3], 'node x')
test.end()
})
tape('sankey() sets node positions using nodePosition()', test => {
const {graph} = example4to1()
sankey().scale(0.2).nodePosition(d => [parseFloat(d.id), parseFloat(d.id)])(graph)
test.deepEqual(nodeAttr(graph, d => d.y1 - d.y0), [1, 1, 1, 1, 4], 'node heights')
assertAlmostEqual(test, nodeAttr(graph, d => d.y0), [0, 1, 2, 3, 4], 1e-6, 'node y')
assertAlmostEqual(test, nodeAttr(graph, d => d.x0), [0, 1, 2, 3, 4], 1e-6, 'node x0')
assertAlmostEqual(test, nodeAttr(graph, d => d.x1), [1, 2, 3, 4, 5], 1e-6, 'node x1')
test.end()
})
tape('sankey() sets link.dy and link.points', test => {
const {graph, ordering} = example4to1()
sankey().size([3, 8]).ordering(ordering)(graph)
test.deepEqual(graph.links.map(l => l.dy), [1, 1, 1, 1], 'link thicknesses')
const n0 = graph.nodes[0]
const n4 = graph.nodes[4]
const l0 = graph.links[0]
assertAlmostEqual(test, l0.points[0].x, n0.x1, 1e-3, 'l0 x0')
assertAlmostEqual(test, l0.points[0].y, n0.y0 + l0.dy / 2, 1e-3, 'l0 y0')
assertAlmostEqual(test, l0.points[1].x, n4.x0, 1e-3, 'l0 x1')
assertAlmostEqual(test, l0.points[1].y, n4.y0 + l0.dy / 2, 1e-3, 'l0 y1')
const n1 = graph.nodes[1]
const l1 = graph.links[1]
assertAlmostEqual(test, l1.points[0].x, n1.x1, 1e-3, 'l1 x0')
assertAlmostEqual(test, l1.points[0].y, n1.y0 + l1.dy / 2, 1e-3, 'l1 y0')
assertAlmostEqual(test, l1.points[1].x, n4.x0, 1e-3, 'l1 x1')
assertAlmostEqual(test, l1.points[1].y, n4.y0 + l0.dy + l1.dy / 2, 1e-3, 'l1 y1')
test.end()
})
tape('sankey() sets link directions', test => {
const graph = {
nodes: [
{id: 'a'},
{id: 'b', direction: 'l'}
],
links: [
{source: 'a', target: 'b', value: 1}
]
}
sankey().ordering([['a', 'b']]).size([10, 12])(graph)
test.equal(graph.links[0].points[0].d, 'r', 'out direction')
test.equal(graph.links[0].points[1].d, 'l', 'in direction')
test.end()
})
tape('sankey() sets link.points on long links', test => {
// 0 1 2 3 4 5 6 7 8 9
// a --- b --- c --- d
// `----*-----*----`
//
const graph = {
nodes: [
{id: 'a'},
{id: 'b'},
{id: 'c'},
{id: 'd'}
],
links: [
{source: 'a', target: 'b', value: 1},
{source: 'b', target: 'c', value: 1},
{source: 'c', target: 'd', value: 1},
{source: 'a', target: 'd', value: 1}
]
}
sankey().size([10, 12])(graph)
test.deepEqual(graph.links.map(l => l.dy), [ 3, 3, 3, 3 ])
test.deepEqual(graph.links[0].points, [
{ x: 1, y: 7.5, ro: 1.5, d: 'r' },
{ x: 3, y: 9.3, ri: 1.5, d: 'r' }
])
test.deepEqual(graph.links[3].points, [
{ x: 1, y: 4.5, ro: 1.5, d: 'r' },
{ x: 3.5, y: 2.7, ri: 1.5, ro: Infinity, d: 'r' },
{ x: 6.5, y: 2.7, ri: Infinity, ro: 1.5, d: 'r' },
{ x: 9, y: 4.5, ri: 1.5, d: 'r' }
])
test.end()
})
// XXX need to fix sankey.update()
tape.skip('sankey.update() sets node.{x0, y0, x1, y1}, link.dy and link.points based on existing node positions', test => {
const graph = {
nodes: [
{id: 'a', x0: 1, x1: 2, y: 1},
{id: 'b', x0: 3, x1: 4, y: 1}
],
links: [
{source: 'a', target: 'b', value: 1}
]
}
sankey().scale(1).update(graph)
test.equal(graph.links[0].dy, 1)
test.deepEqual(graph.links[0].points, [
{ x: 2, y: 1.5, ro: Infinity },
{ x: 3, y: 1.5, ri: Infinity }
])
test.end()
})
tape('sankey().size() sets width and height', test => {
const graph = {
nodes: [
{id: '0'},
{id: '1'}
],
links: [
{source: '0', target: '1', value: 5}
]
}
sankey().size([100, 100])(graph)
test.deepEqual(nodeAttr(graph, d => d.x0), [0, 99], 'x')
test.deepEqual(nodeAttr(graph, d => d.y0), [25, 25], 'y')
sankey().size([200, 200])(graph)
test.deepEqual(nodeAttr(graph, d => d.x0), [0, 199], 'x')
test.deepEqual(nodeAttr(graph, d => d.y0), [50, 50], 'y')
test.end()
})
tape('sankey().extent() sets x0, y0, x1, y1', test => {
const graph = {
nodes: [
{id: '0'},
{id: '1'}
],
links: [
{source: '0', target: '1', value: 5}
]
}
sankey().extent([[100, 100], [200, 200]])(graph)
test.deepEqual(nodeAttr(graph, d => d.x0), [100, 199], 'x')
test.deepEqual(nodeAttr(graph, d => d.y0), [125, 125], 'y')
test.end()
})
tape('sankey.rankSets() affects ranking of nodes', test => {
const graph = {
nodes: [
{id: '0'},
{id: '1'},
{id: '2'},
{id: '3'}
],
links: [
{source: '0', target: '1', value: 5},
{source: '1', target: '2', value: 5},
{source: '0', target: '3', value: 5}
]
}
sankey()(graph)
test.deepEqual(nodeAttr(graph, d => d.rank), [0, 1, 2, 1], 'no rankSets')
sankey().rankSets([{ type: 'same', nodes: ['2', '3'] }])(graph)
test.deepEqual(nodeAttr(graph, d => d.rank), [0, 1, 2, 2], 'with rankSets')
test.end()
})
tape('sankey() horizontal positioning', test => {
function nodeX (width) {
const graph = {
nodes: [{id: '0'}, {id: '1'}, {id: '2'}],
links: [
{source: '0', target: '1', value: 3},
{source: '1', target: '2', value: 3}
]
}
sankey().ordering([['0'], ['1'], ['2']]).size([width, 1])(graph)
return graph.nodes.map(d => d.x0)
}
test.deepEqual(nodeX(7), [0, 3, 6], 'equal when straight')
// test.deepEqual(nodeX([6, 0]), [0, 8, 10], 'min width moves x position');
// test.deepEqual(nodeX([6, 2]), [0, 7, 10], 'min width moves x position 2');
// test.deepEqual(nodeX([7, 5]), [0, 10*7/12, 10], 'width allocated fairly if insufficient');
test.end()
})
function nodeAttr (graph, f) {
const r = graph.nodes.map(d => [d.id, f(d)])
r.sort((a, b) => a[0].localeCompare(b[0]))
return r.map(d => d[1])
}
tape('sankey() nodes with zero value are ignored', test => {
const graph = {
nodes: [
{id: '0'},
{id: '1'},
{id: '2'},
{id: '3'},
{id: '4'}
],
links: [
{source: '0', target: '4', value: 5},
{source: '1', target: '4', value: 5},
{source: '2', target: '4', value: 0}, // NB value = 0
{source: '3', target: '4', value: 5}
]
}
sankey().ordering([['0', '1', '2', '3'], ['4']])(graph)
const y = nodeAttr(graph, d => d.y0)
const sep01 = y[1] - y[0]
const sep13 = y[3] - y[1]
test.equal(graph.nodes[2].y1 - graph.nodes[2].y0, 0, 'node 2 should have no height')
assertAlmostEqual(test, sep01, sep13, 1e-6, 'node 2 should not affect spacing of others')
test.end()
})
tape('justifiedPositioning: bands', test => {
// 0 -- 2 : band x
//
// 1 -- 3 : band y
// `- 4 :
//
const graph = {
nodes: [
{id: '0'},
{id: '1'},
{id: '2'},
{id: '3'},
{id: '4'}
],
links: [
{source: '0', target: '2', value: 5},
{source: '1', target: '3', value: 10},
{source: '1', target: '4', value: 15}
]
}
const s = sankey()
.size([1, 8])
.ordering([ [['0'], []], [['2'], ['1']], [[], ['3', '4']] ])
const nodes = nodeAttr(s(graph), d => d)
// 50% whitespace: scale = 8 / 20 * 0.5 = 0.2
const margin = (5 / 30) * 8 / 5
// Bands should not overlap
const yb = nodes[0].y1 + margin
test.ok(nodes[0].y0 >= margin, 'node 0 >= margin')
test.ok(nodes[2].y0 >= margin, 'node 2 >= margin')
test.ok(nodes[0].y1 < yb, 'node 0 above boundary')
test.ok(nodes[2].y1 < yb, 'node 2 above boundary')
test.ok(nodes[1].y0 > yb, 'node 1 below boundary')
test.ok(nodes[3].y0 > yb, 'node 3 below boundary')
test.ok(nodes[4].y0 > yb, 'node 4 below boundary')
test.ok(nodes[1].y1 <= 8, 'node 1 within height')
test.ok(nodes[3].y1 <= 8, 'node 3 within height')
test.ok(nodes[4].y1 <= 8, 'node 4 within height')
test.end()
})
function example4to1 () {
// 0|---\
// \
// 1|-\ -|
// \---|4
// 2|------|
// ,-|
// 3|---/
//
return {
graph: {
nodes: [
{id: '0'},
{id: '1'},
{id: '2'},
{id: '3'},
{id: '4'}
],
links: [
{source: '0', target: '4', value: 5},
{source: '1', target: '4', value: 5},
{source: '2', target: '4', value: 5},
{source: '3', target: '4', value: 5}
]
},
ordering: [['0', '1', '2', '3'], ['4']]
}
}
tape('sankey.ordering(order) sets rank, band and depth', test => {
function resultForOrder (order) {
var graph = {
nodes: [
{id: 'a'},
{id: 'b'},
{id: 'c'},
{id: 'd'}
],
links: [
{source: 'a', target: 'b'},
{source: 'a', target: 'c'},
{source: 'c', target: 'd'}
]
}
return rankBandAndDepth(sankey().ordering(order)(graph))
}
test.deepEqual(resultForOrder([['a'], ['b', 'c'], ['d']]), {
a: [0, undefined, 0],
b: [1, undefined, 0],
c: [1, undefined, 1],
d: [2, undefined, 0]
}, '2-level order')
test.deepEqual(resultForOrder([[['a'], []], [['b', 'c'], []], [[], ['d']]]), {
a: [0, 0, 0],
b: [1, 0, 0],
c: [1, 0, 1],
d: [2, 1, 0]
}, '3-level order')
test.end()
})
tape('sankey.ordering() returns ordering', test => {
var order = [['a'], ['b', 'c'], ['d']]
test.equal(sankey().ordering(order).ordering(), order)
test.end()
})
function rankBandAndDepth (G) {
const r = {}
G.nodes.forEach(d => {
r[d.id] = [d.rank, d.band, d.depth]
})
return r
}