-
Notifications
You must be signed in to change notification settings - Fork 16
Expand file tree
/
Copy pathChartSlider.jsx
More file actions
308 lines (239 loc) · 9.06 KB
/
ChartSlider.jsx
File metadata and controls
308 lines (239 loc) · 9.06 KB
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
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
import React, { PropTypes, Children } from 'react';
import ReactDOM from 'react-dom';
import d3 from 'd3';
import './style.scss';
export default class ChartSlider extends React.Component {
// property validation
static propTypes = {
width: PropTypes.number,
height: PropTypes.number,
scale: PropTypes.func,
orient: PropTypes.string,
margin: PropTypes.shape({
top: PropTypes.number,
right: PropTypes.number,
bottom: PropTypes.number,
left: PropTypes.number
}),
children: React.PropTypes.node,
selectedValue: PropTypes.number,
onClickOrMove: PropTypes.func
};
// property defaults (ES7-style React)
// (instead of ES5-style getDefaultProps)
static defaultProps = {
scale: d3.scale.linear()
.clamp(true),
orient: 'bottom',
margin: {
top: 20,
right: 30,
bottom: 20,
left: 30
},
onClickOrMove: null
};
constructor (props) {
super(props);
// bind handlers to this component instance,
// since React no longer does this automatically when using ES6
// this.onThingClicked = this.onThingClicked.bind(this);
}
componentWillMount () {
}
componentDidMount () {
d3ChartSlider.create(this.refs.axis, this.props.scale, this.props.orient, this.props.margin, this.props.onClickOrMove);
// Attempt to measure container width, to pass down to child component
try {
this.containerNode = ReactDOM.findDOMNode(this);
} catch (e) {}
// Rerender in order to pass measured width down to child component
this.forceUpdate();
}
componentDidUpdate () {
d3ChartSlider.update(this.refs.axis, this.props.scale, this.props.orient, this.props.margin, this.props.selectedValue, this.props.onClickOrMove);
// Attempt to measure container width, to pass down to child component
try {
this.containerNode = ReactDOM.findDOMNode(this);
} catch (e) {}
}
componentWillUnmount () {
d3ChartSlider.destroy(this.refs.axis);
}
render () {
let numChildren = Children.count(this.props.children);
if (numChildren > 1) {
console.warn(`ChartSlider is designed to wrap only one child component, but it found ${ numChildren } children.`);
}
return (
<div className='panorama chart-slider'>
{
// Set width/height on the single child component
React.cloneElement(this.props.children, {
width: this.containerNode ? this.containerNode.offsetWidth : this.props.width,
height: this.props.height
})
}
<div className='top-rule' style={ {
marginLeft: this.props.margin.left + 'px',
marginRight: this.props.margin.right + 'px',
width: `calc(100% - ${ this.props.margin.left + this.props.margin.right }px)`
} } />
<div className='d3-chart-slider' ref='axis'/>
</div>
);
}
}
const d3ChartSlider = {
/**
* Any necessary setup for d3 component goes here.
*
* @param {Node} HTMLElement to which d3 will attach
* @param {Function} d3 scale to use for the axis
* @param {String} orientation of the axis (per d3.axis.orient)
* @param {Object} Object specifying margins around the component
* @param {Function} Chart click/move handler
*/
create: function (node, scale, orient, margin, onClickOrMove) {
this.onBrushMoved = this.onBrushMoved.bind(this);
// TODO: would be nice to not have to maintain this state.
// It's needed in onBrushMove() (and is updated in update());
// if d3.event wasn't null in the event handler, could probably use event.target...
this.node = node;
const primaryAxisTickSize = 13;
this.axisPrimary = d3.svg.axis()
.orient(orient)
.ticks(5)
.tickFormat(String)
.tickSize(primaryAxisTickSize);
this.axisSecondary = d3.svg.axis()
.orient(orient)
.ticks(10)
.tickFormat(d => '')
.tickSize(primaryAxisTickSize - 3);
this.axisTertiary = d3.svg.axis()
.orient(orient)
.ticks(40)
.tickFormat(d => '')
.tickSize(primaryAxisTickSize - 6);
this.brush = d3.svg.brush()
.on('brush', this.onBrushMoved);
let svg = d3.select(node).append('svg');
svg.append('g')
.attr('class', 'axis tertiary');
svg.append('g')
.attr('class', 'axis secondary');
svg.append('g')
.attr('class', 'axis primary');
this.handle = svg.append('g')
.attr('class', 'handle');
let height = node.offsetHeight - margin.bottom + primaryAxisTickSize + 3, // eyeballing it...
handleElements = this.handle.append('g')
.attr('class', 'handle-elements');
handleElements.append('line')
.attr({
'x1': 0,
'x2': 0,
'y1': 0,
'y2': height
});
let capSize = 10;
handleElements.append('path')
// rounded triangle path, at 100x100; scale down as needed
.attr('d', 'M 30 0 L 70 0 C 85 0 93.29179606750063 13.416407864998739 86.58359213500125 26.832815729997478 L 63.41640786499873 73.16718427000252 C 56.708203932499366 86.58359213500125 43.29179606750063 86.58359213500125 36.58359213500126 73.16718427000252 L 13.416407864998739 26.832815729997478 C 6.708203932499369 13.416407864998739 15 0 30 0 Z ')
.attr('transform', 'scale(' + capSize/100 + ') translate(-50, 0)');
handleElements.append('path')
// rounded triangle path, at 100x100; scale down as needed
.attr('d', 'M 30 0 L 70 0 C 85 0 93.29179606750063 13.416407864998739 86.58359213500125 26.832815729997478 L 63.41640786499873 73.16718427000252 C 56.708203932499366 86.58359213500125 43.29179606750063 86.58359213500125 36.58359213500126 73.16718427000252 L 13.416407864998739 26.832815729997478 C 6.708203932499369 13.416407864998739 15 0 30 0 Z ')
.attr('transform', 'rotate(180) scale(' + capSize/100 + ') translate(-50, ' + -(100/capSize * height) + ')');
this.update(node, scale, orient, margin);
},
/**
* Logic for updating d3 component with new data.
*
* @param {Node} HTMLElement to which d3 will attach
* @param {Function} d3 scale to use for the axis
* @param {String} orientation of the axis (per d3.axis.orient)
* @param {Object} Object specifying margins around the component
* @param {Number} Scaled location of the slider
* @param {Function} Chart click/move handler
*/
update: function (node, scale, orient, margin, selectedValue, onClickOrMove) {
this.node = node;
this.onClickOrMove = onClickOrMove;
// update axis
scale.range([0, node.offsetWidth - margin.left - margin.right]);
this.axisPrimary.scale(scale);
this.axisSecondary.scale(scale);
this.axisTertiary.scale(scale);
this.brush.x(scale);
// apply size and position
let axisTranform = `translate(${ margin.left }, ${ node.offsetHeight - margin.bottom })`;
let svg = d3.select(node).select('svg');
svg
.attr('width', '100%')
.attr('height', '100%');
// draw axes
svg.select('.axis.primary')
.call(this.axisPrimary)
.attr('transform', axisTranform)
// position labels
.selectAll('text')
.attr('y', Math.floor(2/3 * margin.bottom));
// draw secondary and tertiary axes (just smaller ticks)
svg.select('.axis.secondary')
.call(this.axisSecondary)
.attr('transform', axisTranform);
svg.select('.axis.tertiary')
.call(this.axisTertiary)
.attr('transform', axisTranform);
// draw brush
// let slider = svg.select('.slider');
this.handle
.call(this.brush)
.attr('transform', `translate(${ margin.left }, 0)`)
.select('.background')
.on('mousedown.brush', this.onBrushMoved)
.on('touchstart.brush', this.onBrushMoved);
this.handle.selectAll('.background')
.attr('height', '100%');
if (typeof selectedValue !== 'undefined') {
this.onSelectedValueChanged(selectedValue);
}
},
/**
* Any necessary cleanup for d3 component goes here.
*
* @param {Node} HTMLElement to which d3 was attached
*/
destroy: function (node) {
d3.select(node).html('');
this.node = null;
this.axisPrimary = null;
this.axisSecondary = null;
this.axisTertiary = null;
this.brush = null;
this.handle = null;
},
onBrushMoved: function () {
let scale = this.brush.x(),
domain = scale.domain(),
mouseX = d3.mouse(d3.select(this.node).select('.axis')[0][0])[0], // there's probably a better, more-d3 way to do this...
value = scale.invert(mouseX);
// clamp and quantize
value = Math.round(Math.max(domain[0], Math.min(domain[1], value)));
// Direct communication: call callback if it was passed in.
if (this.onClickOrMove) {
this.onClickOrMove(value);
}
// Indirect communication: Notify any subscribers of chart click/move.
// PanoramaDispatcher.ChartSlider.clickMove(value);
},
onSelectedValueChanged: function (value) {
this.handle
.call(this.brush.extent([value, value + 2]));
let brushCenter = this.brush.x()(value);
this.handle.select('.handle-elements')
.attr('transform', `translate(${ brushCenter }, 0)`);
}
};