OLD | NEW |
| (Empty) |
1 // | |
2 // Copyright 2014 Google Inc. All rights reserved. | |
3 // | |
4 // Use of this source code is governed by a BSD-style | |
5 // license that can be found in the LICENSE file or at | |
6 // https://developers.google.com/open-source/licenses/bsd | |
7 // | |
8 | |
9 part of charted.charts; | |
10 | |
11 typedef Element HovercardBuilder(int column, int row); | |
12 | |
13 /// | |
14 /// Subscribe to events on the chart and display more information about the | |
15 /// visualization that is hovered or highlighted. | |
16 /// | |
17 /// This behavior supports two event modes: | |
18 /// (1) State change: Subscribes to changes on ChartState | |
19 /// (2) Mouse tracking: Subscribe to onValueMouseOver and onValueMouseOut | |
20 /// | |
21 /// Supports two placement modes for mouse-tracking: | |
22 /// (1) Place relative to mouse position | |
23 /// (2) Place relative to the visualized measure value. | |
24 /// | |
25 /// Supports two modes for displayed content: | |
26 /// (1) Show all measure values at the current dimension value. | |
27 /// (2) Show the hovered value only | |
28 /// | |
29 /// Optionally, takes a builder that is passed row, column values that | |
30 /// can be used to build custom tooltip | |
31 /// | |
32 /// What makes the positioning logic complex? | |
33 /// (1) Is this a CartesianArea? | |
34 /// (2) Does the CartesianArea use two dimensions or just one? | |
35 /// (3) Does the CartesianArea use "bands" along the axes? | |
36 /// (4) How does measure correspond to positioning? Are the bars stacked? | |
37 /// | |
38 /// So, how is the position computed? | |
39 /// (1) Uses ChartConfig to figure out which renderers are being used, asks | |
40 /// for extent of the row to roughly get the position along measure axis. | |
41 /// (2) Position along dimension axes is computed separately based on how | |
42 /// many dimensions are being used and if any of them use bands. | |
43 /// | |
44 /// Constraints and known issues: | |
45 /// (0) The implementation isn't complete yet! Specifically for CartesianArea | |
46 /// that uses two axes. | |
47 /// (1) Even with all the logic, single value mode does not work well | |
48 /// with StackedBarChartRenderer | |
49 /// (2) Only mouse relative positioning is supported on LayoutArea | |
50 /// (3) Positioning only works for renderers that determine extent given a | |
51 /// single row. Eg: Would not work with a water-fall chart. | |
52 /// | |
53 class Hovercard implements ChartBehavior { | |
54 final HovercardBuilder builder; | |
55 | |
56 bool _isMouseTracking; | |
57 bool _isMultiValue; | |
58 bool _showDimensionTitle; | |
59 Iterable<int> _columnsToShow; | |
60 | |
61 Iterable placementOrder = | |
62 const['orientation', 'top', 'right', 'bottom', 'left', 'orientation']; | |
63 int offset = 20; | |
64 | |
65 ChartArea _area; | |
66 ChartState _state; | |
67 SubscriptionsDisposer _disposer = new SubscriptionsDisposer(); | |
68 | |
69 Element _hovercardRoot; | |
70 | |
71 Hovercard({ | |
72 bool isMouseTracking, | |
73 bool isMultiValue: false, | |
74 bool showDimensionTitle: false, | |
75 List columnsToShow: const [], | |
76 this.builder}) { | |
77 _isMouseTracking = isMouseTracking; | |
78 _isMultiValue = isMultiValue; | |
79 _showDimensionTitle = showDimensionTitle; | |
80 _columnsToShow = columnsToShow; | |
81 } | |
82 | |
83 void init(ChartArea area, Selection _, Selection __) { | |
84 _area = area; | |
85 _state = area.state; | |
86 | |
87 // If we don't have state, fall back to mouse events. | |
88 _isMouseTracking = | |
89 _isMouseTracking == true || _state == null || _area is LayoutArea; | |
90 | |
91 // Subscribe to events. | |
92 if (_isMouseTracking) { | |
93 _disposer.addAll([ | |
94 _area.onValueMouseOver.listen(_handleMouseOver), | |
95 _area.onValueMouseOut.listen(_handleMouseOut) | |
96 ]); | |
97 } else { | |
98 _disposer.add(_state.changes.listen(_handleStateChange)); | |
99 } | |
100 } | |
101 | |
102 void dispose() { | |
103 _disposer.dispose(); | |
104 if (_hovercardRoot != null) _hovercardRoot.remove(); | |
105 } | |
106 | |
107 void _handleMouseOver(ChartEvent e) { | |
108 _ensureHovercard(); | |
109 _hovercardRoot.children.clear(); | |
110 _hovercardRoot.append(builder != null | |
111 ? builder(e.column, e.row) | |
112 : _createTooltip(e.column, e.row)); | |
113 _hovercardRoot.style | |
114 ..visibility = 'visible' | |
115 ..opacity = '1.0'; | |
116 _updateTooltipPosition(evt: e); | |
117 } | |
118 | |
119 void _handleMouseOut(ChartEvent e) { | |
120 _ensureHovercard(); | |
121 _hovercardRoot.style | |
122 ..visibility = 'hidden' | |
123 ..opacity = '$EPSILON'; | |
124 } | |
125 | |
126 void _handleStateChange(Iterable<ChangeRecord> changes) { | |
127 _ensureHovercard(); | |
128 | |
129 var value = _state.hovered; | |
130 if (_state.highlights.length == 1) { | |
131 value = _state.highlights.first; | |
132 } | |
133 | |
134 if (value == null) { | |
135 _hovercardRoot.style | |
136 ..visibility = 'hidden' | |
137 ..opacity = '$EPSILON'; | |
138 } else { | |
139 _hovercardRoot.children.clear(); | |
140 _hovercardRoot.append(builder != null | |
141 ? builder(value.first, value.last) | |
142 : _createTooltip(value.first, value.last)); | |
143 _hovercardRoot.style | |
144 ..visibility = 'visible' | |
145 ..opacity = '1.0'; | |
146 _updateTooltipPosition(column: value.first, row: value.last); | |
147 } | |
148 } | |
149 | |
150 void _ensureHovercard() { | |
151 if (_hovercardRoot != null) return; | |
152 _hovercardRoot = new Element.div(); | |
153 _hovercardRoot.classes.add('hovercard'); | |
154 if (_area.config.isRTL) { | |
155 _hovercardRoot.attributes['dir'] = 'rtl'; | |
156 _hovercardRoot.classes.add('rtl'); | |
157 } | |
158 _area.host.style.position = 'relative'; | |
159 _area.host.append(_hovercardRoot); | |
160 } | |
161 | |
162 void _updateTooltipPosition({ChartEvent evt, int column, int row}) { | |
163 assert(evt != null || column != null && row != null); | |
164 if (_isMouseTracking && evt != null) { | |
165 _positionAtMousePointer(evt); | |
166 } else if (_area is CartesianArea) { | |
167 if ((_area as CartesianArea).useTwoDimensionAxes) { | |
168 _positionOnTwoDimensionCartesian(column, row); | |
169 } else { | |
170 _positionOnSingleDimensionCartesian(column, row); | |
171 } | |
172 } else { | |
173 _positionOnLayout(column, row); | |
174 } | |
175 } | |
176 | |
177 void _positionAtMousePointer(ChartEvent e) => | |
178 _positionAtPoint(e.chartX, e.chartY, offset, offset, false, false); | |
179 | |
180 void _positionOnLayout(column, row) { | |
181 // Currently for layouts, when hovercard is triggered due to change | |
182 // in ChartState, we render hovercard in the middle of layout. | |
183 // TODO: Get bounding rect from LayoutRenderer and position relative to it. | |
184 } | |
185 | |
186 void _positionOnTwoDimensionCartesian(int column, int row) { | |
187 // TODO: Implement multi dimension positioning. | |
188 } | |
189 | |
190 void _positionOnSingleDimensionCartesian(int column, int row) { | |
191 CartesianArea area = _area; | |
192 var dimensionCol = area.config.dimensions.first, | |
193 dimensionScale = area.dimensionScales.first, | |
194 measureScale = _getScaleForColumn(column), | |
195 dimensionOffset = this.offset, | |
196 dimensionCenterOffset = 0; | |
197 | |
198 // If we are using bands on the one axis that is shown | |
199 // update position and offset accordingly. | |
200 if (area.dimensionsUsingBands.contains(dimensionCol)) { | |
201 assert(dimensionScale is OrdinalScale); | |
202 dimensionOffset = (dimensionScale as OrdinalScale).rangeBand / 2; | |
203 dimensionCenterOffset = dimensionOffset; | |
204 } | |
205 | |
206 var rowData = area.data.rows.elementAt(row), | |
207 measurePosition = 0, | |
208 isNegative = false, | |
209 dimensionPosition = dimensionScale.scale( | |
210 rowData.elementAt(dimensionCol)) + dimensionCenterOffset; | |
211 | |
212 if (_isMultiValue) { | |
213 var max = SMALL_INT_MIN, | |
214 min = SMALL_INT_MAX; | |
215 area.config.series.forEach((ChartSeries series) { | |
216 CartesianRenderer renderer = series.renderer; | |
217 Extent extent = renderer.extentForRow(rowData); | |
218 if (extent.min < min) min = extent.min; | |
219 if (extent.max > max) max = extent.max; | |
220 measurePosition = measureScale.scale(max); | |
221 isNegative = max < 0; | |
222 }); | |
223 } else { | |
224 var value = rowData.elementAt(column); | |
225 isNegative = value < 0; | |
226 measurePosition = measureScale.scale(rowData.elementAt(column)); | |
227 } | |
228 | |
229 if (area.config.isLeftAxisPrimary) { | |
230 _positionAtPoint(measurePosition, dimensionPosition, | |
231 0, dimensionOffset, isNegative, true); | |
232 } else { | |
233 _positionAtPoint(dimensionPosition, measurePosition, | |
234 dimensionOffset, 0, isNegative, false); | |
235 } | |
236 } | |
237 | |
238 void _positionAtPoint(num x, num y, | |
239 num xBand, num yBand, bool negative, [bool isLeftPrimary = false]) { | |
240 var rect = _hovercardRoot.getBoundingClientRect(), | |
241 width = rect.width, | |
242 height = rect.height, | |
243 scaleToHostY = | |
244 (_area.theme.padding != null ? _area.theme.padding.top : 0) + | |
245 (_area.layout.renderArea.y), | |
246 scaleToHostX = | |
247 (_area.theme.padding != null ? _area.theme.padding.start: 0) + | |
248 (_area.layout.renderArea.x), | |
249 renderAreaHeight = _area.layout.renderArea.height, | |
250 renderAreaWidth = _area.layout.renderArea.width; | |
251 | |
252 if (scaleToHostY < 0) scaleToHostY = 0; | |
253 if (scaleToHostX < 0) scaleToHostX = 0; | |
254 | |
255 num top = 0, left = 0; | |
256 for (int i = 0, len = placementOrder.length; i < len; ++i) { | |
257 String placement = placementOrder.elementAt(i); | |
258 | |
259 // Place the popup based on the orientation. | |
260 if (placement == 'orientation') { | |
261 placement = isLeftPrimary ? 'right' : 'top'; | |
262 } | |
263 | |
264 if (placement == 'top') { | |
265 top = negative ? y + yBand : y - (height + yBand); | |
266 left = isLeftPrimary ? x - width : x - width / 2; | |
267 } | |
268 if (placement == 'right') { | |
269 top = isLeftPrimary ? y - height / 2 : y; | |
270 left = negative ? x - (width + xBand) : x + xBand; | |
271 } | |
272 if (placement == 'left') { | |
273 top = isLeftPrimary ? y - height / 2 : y; | |
274 left = negative ? x + xBand : x - (width + xBand); | |
275 } | |
276 if (placement == 'bottom') { | |
277 top = negative ? y - (height + yBand) : y + yBand; | |
278 left = isLeftPrimary ? x - width : x - width / 2; | |
279 } | |
280 | |
281 // Check if the popup is contained in the RenderArea. | |
282 // If not, try other placements. | |
283 if (top > 0 && left > 0 && | |
284 top + height < renderAreaHeight && left + width < renderAreaWidth) { | |
285 break; | |
286 } | |
287 } | |
288 | |
289 _hovercardRoot.style | |
290 ..top = '${top + scaleToHostY}px' | |
291 ..left = '${left + scaleToHostX}px'; | |
292 } | |
293 | |
294 Element _createTooltip(int column, int row) { | |
295 var element = new Element.div(); | |
296 if (_showDimensionTitle) { | |
297 var titleElement = new Element.div() | |
298 ..className = 'hovercard-title' | |
299 ..text = _getDimensionTitle(column, row); | |
300 element.append(titleElement); | |
301 } | |
302 | |
303 var measureVals = _getMeasuresData(column, row); | |
304 measureVals.forEach((ChartLegendItem item) { | |
305 var labelElement = new Element.div() | |
306 ..className = 'hovercard-measure-label' | |
307 ..text = item.label, | |
308 valueElement = new Element.div() | |
309 ..style.color = item.color | |
310 ..className = 'hovercard-measure-value' | |
311 ..text = item.value, | |
312 measureElement = new Element.div() | |
313 ..append(labelElement) | |
314 ..append(valueElement); | |
315 | |
316 measureElement.className = _columnsToShow.length > 1 || _isMultiValue | |
317 ? 'hovercard-measure hovercard-multi' | |
318 : 'hovercard-measure hovercard-single'; | |
319 element.append(measureElement); | |
320 }); | |
321 | |
322 return element; | |
323 } | |
324 | |
325 Iterable<ChartLegendItem> _getMeasuresData(int column, int row) { | |
326 var measureVals = <ChartLegendItem>[]; | |
327 | |
328 if (_columnsToShow.isNotEmpty) { | |
329 _columnsToShow.forEach((int column) { | |
330 measureVals.add(_createHovercardItem(column, row)); | |
331 }); | |
332 } else if (_columnsToShow.length > 1 || _isMultiValue) { | |
333 var displayedCols = []; | |
334 _area.config.series.forEach((ChartSeries series) { | |
335 series.measures.forEach((int column) { | |
336 if (!displayedCols.contains(column)) displayedCols.add(column); | |
337 }); | |
338 }); | |
339 displayedCols.sort(); | |
340 displayedCols.forEach((int column) { | |
341 measureVals.add(_createHovercardItem(column, row)); | |
342 }); | |
343 } else { | |
344 measureVals.add(_createHovercardItem(column, row)); | |
345 } | |
346 | |
347 return measureVals; | |
348 } | |
349 | |
350 ChartLegendItem _createHovercardItem(int column, int row) { | |
351 var rowData = _area.data.rows.elementAt(row), | |
352 columns = _area.data.columns, | |
353 spec = columns.elementAt(column), | |
354 colorKey = _area.useRowColoring ? row : column, | |
355 formatter = _getFormatterForColumn(column), | |
356 label = _area.useRowColoring | |
357 ? rowData.elementAt(_area.config.dimensions.first) | |
358 : spec.label; | |
359 return new ChartLegendItem( | |
360 label: label, | |
361 value: formatter(rowData.elementAt(column)), | |
362 color: _area.theme.getColorForKey(colorKey)); | |
363 } | |
364 | |
365 String _getDimensionTitle(int column, int row) { | |
366 var rowData = _area.data.rows.elementAt(row), | |
367 colSpec = _area.data.columns.elementAt(column); | |
368 if (_area.useRowColoring) { | |
369 return colSpec.label; | |
370 } else { | |
371 var count = (_area as CartesianArea).useTwoDimensionAxes ? 2 : 1, | |
372 dimensions = _area.config.dimensions.take(count); | |
373 return dimensions.map( | |
374 (int c) => | |
375 _getFormatterForColumn(c)(rowData.elementAt(c))).join(', '); | |
376 } | |
377 } | |
378 | |
379 // TODO: Move this to a common place? | |
380 Scale _getScaleForColumn(int column) { | |
381 var series = _area.config.series.firstWhere( | |
382 (ChartSeries x) => x.measures.contains(column), orElse: () => null); | |
383 return series != null | |
384 ? (_area as CartesianArea).measureScales(series).first | |
385 : null; | |
386 } | |
387 | |
388 // TODO: Move this to a common place? | |
389 FormatFunction _getFormatterForColumn(int column) { | |
390 var formatter = _area.data.columns.elementAt(column).formatter; | |
391 if (formatter == null && _area is CartesianArea) { | |
392 var scale = _getScaleForColumn(column); | |
393 if (scale != null) { | |
394 formatter = scale.createTickFormatter(); | |
395 } | |
396 } | |
397 if (formatter == null) { | |
398 // Formatter function must return String. Default to identity function | |
399 // but return the toString() instead. | |
400 formatter = (x) => x.toString(); | |
401 } | |
402 return formatter; | |
403 } | |
404 } | |
OLD | NEW |