| 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 |