-
Notifications
You must be signed in to change notification settings - Fork 1
/
Copy pathBubbleChart.js
164 lines (138 loc) · 5 KB
/
BubbleChart.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
import * as d3 from 'd3';
const forceStrength = 0.03;
// Threshold where, when the simulation alpha goes below this, we will send a
// message to the Elm app to inform it that the current chart simulation is
// almost complete.
const chartSettledThreshold = 0.2;
export default class BubbleChart {
constructor(app, selector) {
this.app = app;
this.svg = d3.select(selector);
this.width = this.svg.attr('width');
this.height = this.svg.attr('height');
const left = {x: this.width / 3, y: this.height / 2};
const center = {x: this.width / 2, y: this.height / 2};
const right = {x: 2 * this.width / 3, y: this.height / 2};
this.positions = {
yes: left,
absent: center,
both: center,
no: right,
};
this.bubbles = null;
this.nodes = null;
// Called after every tick of the force simulation. Here we do the actual
// repositioning of the SVG circles based on the current x and y values of
// their bound node data. These x and y values are modified by the force
// simulation.
this.ticked = () => {
this.bubbles.attr('cx', d => d.x).attr('cy', d => d.y);
this.handleChartSettled();
};
this.simulation = d3
.forceSimulation()
.velocityDecay(0.2)
.force(
'x',
d3
.forceX()
.strength(forceStrength)
.x(d => this.positions[d.option].x),
)
.force(
'y',
d3
.forceY()
.strength(forceStrength)
.y(center.y),
)
.force('charge', d3.forceManyBody().strength(this.charge))
.on('tick', this.ticked);
// Force simulation starts automatically, which we don't want as there aren't
// any nodes yet.
this.simulation.stop();
}
handleChartSettled() {
const alphaBelowThreshold = this.simulation.alpha() < chartSettledThreshold;
const chartJustSettled = !this.chartSettled && alphaBelowThreshold;
if (chartJustSettled) {
this.chartSettled = true;
this.app.ports.chartSettled.send(null);
}
}
// Charge function that is called for each node. As part of the ManyBody
// force. This is what creates the repulsion between nodes.
//
// Charge is proportional to the diameter of the circle (which is stored in
// the radius attribute of the circle's associated data.
//
// This is done to allow for accurate collision detection with nodes of
// different sizes.
//
// Charge is negative because we want nodes to repel.
charge(d) {
return -Math.pow(d.radius, 2.0) * forceStrength;
}
setNodes(rawData, restart = true) {
this.nodes = rawData.map(d => {
const currentNode = ((this.nodes &&
this.nodes.filter(node => node.personId == d.personId)) ||
{})[0];
return {
personId: d.personId,
radius: d.radius,
colour: d.colour,
borderColour: d.borderColour,
option: d.option,
x: currentNode ? currentNode.x : Math.random() * this.width,
y: currentNode ? currentNode.y : Math.random() * this.height,
};
});
// Bind nodes data to what will become DOM elements to represent them.
this.bubbles = this.svg
.selectAll('.bubble')
.data(this.nodes, d => d.personId);
this.bubbles.exit().remove();
// XXX Could de-duplicate these.
const personNodeHovered = node =>
this.app.ports.personNodeHovered.send(node.personId);
const personNodeUnhovered = node =>
this.app.ports.personNodeUnhovered.send(node.personId);
const personNodeClicked = node =>
this.app.ports.personNodeClicked.send(node.personId);
// Create new circle elements each with class `bubble`. There will be one
// circle.bubble for each object in the nodes array. Initially, their
// radius (r attribute) will be 0. Selections are immutable, so lets
// capture the enter selection to apply our transition to below.
const bubblesE = this.bubbles
.enter()
.append('circle')
.classed('bubble', true)
.classed('dim', true)
.attr('r', 0)
.attr('stroke-width', 2)
.on('mouseover', personNodeHovered)
.on('mouseout', personNodeUnhovered)
.on('click', personNodeClicked);
// Merge the original empty selection and the enter selection
this.bubbles = this.bubbles.merge(bubblesE);
// Set/update all bubble colours.
this.bubbles
.attr('fill', d => d.colour)
// Use border colour if given, or generate it if not.
.attr('stroke', d => d.borderColour || d3.rgb(d.colour).darker());
// Fancy transition to make bubbles appear, ending with the correct radius
this.bubbles
.transition()
.duration(2000)
.attr('r', d => d.radius);
// Set the simulation's nodes to our newly created nodes array.
this.simulation.nodes(this.nodes);
if (restart) {
// Reset this so we re-inform the Elm app when the chart settles again.
this.chartSettled = false;
// Reset the alpha value and restart the simulation
this.simulation.alpha(1).restart();
}
}
}