| // |
| // Copyright 2014 Google Inc. All rights reserved. |
| // |
| // Use of this source code is governed by a BSD-style |
| // license that can be found in the LICENSE file or at |
| // https://developers.google.com/open-source/licenses/bsd |
| // |
| |
| part of charted.charts; |
| |
| typedef Element HovercardBuilder(int column, int row); |
| |
| /// |
| /// Subscribe to events on the chart and display more information about the |
| /// visualization that is hovered or highlighted. |
| /// |
| /// This behavior supports two event modes: |
| /// (1) State change: Subscribes to changes on ChartState |
| /// (2) Mouse tracking: Subscribe to onValueMouseOver and onValueMouseOut |
| /// |
| /// Supports two placement modes for mouse-tracking: |
| /// (1) Place relative to mouse position |
| /// (2) Place relative to the visualized measure value. |
| /// |
| /// Supports two modes for displayed content: |
| /// (1) Show all measure values at the current dimension value. |
| /// (2) Show the hovered value only |
| /// |
| /// Optionally, takes a builder that is passed row, column values that |
| /// can be used to build custom tooltip |
| /// |
| /// What makes the positioning logic complex? |
| /// (1) Is this a CartesianArea? |
| /// (2) Does the CartesianArea use two dimensions or just one? |
| /// (3) Does the CartesianArea use "bands" along the axes? |
| /// (4) How does measure correspond to positioning? Are the bars stacked? |
| /// |
| /// So, how is the position computed? |
| /// (1) Uses ChartConfig to figure out which renderers are being used, asks |
| /// for extent of the row to roughly get the position along measure axis. |
| /// (2) Position along dimension axes is computed separately based on how |
| /// many dimensions are being used and if any of them use bands. |
| /// |
| /// Constraints and known issues: |
| /// (0) The implementation isn't complete yet! Specifically for CartesianArea |
| /// that uses two axes. |
| /// (1) Even with all the logic, single value mode does not work well |
| /// with StackedBarChartRenderer |
| /// (2) Only mouse relative positioning is supported on LayoutArea |
| /// (3) Positioning only works for renderers that determine extent given a |
| /// single row. Eg: Would not work with a water-fall chart. |
| /// |
| class Hovercard implements ChartBehavior { |
| static const _HOVERCARD_OFFSET = 20; |
| final HovercardBuilder builder; |
| |
| bool _isMouseTracking; |
| bool _isMultiValue; |
| bool _showDimensionTitle; |
| Iterable<int> _columnsToShow; |
| |
| Iterable placementOrder = const [ |
| 'orientation', |
| 'top', |
| 'right', |
| 'bottom', |
| 'left', |
| 'orientation' |
| ]; |
| |
| ChartArea _area; |
| ChartState _state; |
| SubscriptionsDisposer _disposer = new SubscriptionsDisposer(); |
| |
| Element _hovercardRoot; |
| |
| Hovercard( |
| {bool isMouseTracking, |
| bool isMultiValue: false, |
| bool showDimensionTitle: false, |
| List<int> columnsToShow: const <int>[], |
| this.builder}) { |
| _isMouseTracking = isMouseTracking; |
| _isMultiValue = isMultiValue; |
| _showDimensionTitle = showDimensionTitle; |
| _columnsToShow = columnsToShow; |
| } |
| |
| void init(ChartArea area, Selection _, Selection __) { |
| _area = area; |
| _state = area.state; |
| |
| // If we don't have state, fall back to mouse events. |
| _isMouseTracking = |
| _isMouseTracking == true || _state == null || _area is LayoutArea; |
| |
| // Subscribe to events. |
| if (_isMouseTracking) { |
| _disposer.addAll([ |
| _area.onValueMouseOver.listen(_handleMouseOver), |
| _area.onValueMouseOut.listen(_handleMouseOut) |
| ]); |
| } else { |
| _disposer.add(_state.changes.listen(_handleStateChange)); |
| } |
| } |
| |
| void dispose() { |
| _disposer.dispose(); |
| if (_hovercardRoot != null) _hovercardRoot.remove(); |
| } |
| |
| void _handleMouseOver(ChartEvent e) { |
| _ensureHovercard(); |
| _hovercardRoot.children.clear(); |
| _hovercardRoot.append(builder != null |
| ? builder(e.column, e.row) |
| : _createTooltip(e.column, e.row)); |
| _hovercardRoot.style |
| ..visibility = 'visible' |
| ..opacity = '1.0'; |
| _updateTooltipPosition(evt: e); |
| } |
| |
| void _handleMouseOut(ChartEvent e) { |
| _ensureHovercard(); |
| _hovercardRoot.style |
| ..visibility = 'hidden' |
| ..opacity = '$EPSILON'; |
| } |
| |
| void _handleStateChange(Iterable<ChangeRecord> changes) { |
| _ensureHovercard(); |
| |
| var value = _state.hovered; |
| if (_state.highlights.length == 1) { |
| value = _state.highlights.first; |
| } |
| |
| if (value == null) { |
| _hovercardRoot.style |
| ..visibility = 'hidden' |
| ..opacity = '$EPSILON'; |
| } else { |
| _hovercardRoot.children.clear(); |
| _hovercardRoot.append(builder != null |
| ? builder(value.first, value.last) |
| : _createTooltip(value.first, value.last)); |
| _hovercardRoot.style |
| ..visibility = 'visible' |
| ..opacity = '1.0'; |
| _updateTooltipPosition(column: value.first, row: value.last); |
| } |
| } |
| |
| void _ensureHovercard() { |
| if (_hovercardRoot != null) return; |
| _hovercardRoot = new Element.div(); |
| _hovercardRoot.classes.add('hovercard'); |
| if (_area.config.isRTL) { |
| _hovercardRoot.attributes['dir'] = 'rtl'; |
| _hovercardRoot.classes.add('rtl'); |
| } |
| _area.host.style.position = 'relative'; |
| _area.host.append(_hovercardRoot); |
| } |
| |
| void _updateTooltipPosition({ChartEvent evt, int column, int row}) { |
| assert(evt != null || column != null && row != null); |
| if (_isMouseTracking && evt != null) { |
| _positionAtMousePointer(evt); |
| } else if (_area is CartesianArea) { |
| if ((_area as CartesianArea).useTwoDimensionAxes) { |
| _positionOnTwoDimensionCartesian(column, row); |
| } else { |
| _positionOnSingleDimensionCartesian(column, row); |
| } |
| } else { |
| _positionOnLayout(column, row); |
| } |
| } |
| |
| void _positionAtMousePointer(ChartEvent e) => _positionAtPoint( |
| e.chartX, e.chartY, _HOVERCARD_OFFSET, _HOVERCARD_OFFSET, false, false); |
| |
| void _positionOnLayout(column, row) { |
| // Currently for layouts, when hovercard is triggered due to change |
| // in ChartState, we render hovercard in the middle of layout. |
| // TODO: Get bounding rect from LayoutRenderer and position relative to it. |
| } |
| |
| void _positionOnTwoDimensionCartesian(int column, int row) { |
| // TODO: Implement multi dimension positioning. |
| } |
| |
| void _positionOnSingleDimensionCartesian(int column, int row) { |
| CartesianArea area = _area; |
| var dimensionCol = area.config.dimensions.first, |
| dimensionScale = area.dimensionScales.first, |
| measureScale = _getScaleForColumn(column), |
| dimensionOffset = _HOVERCARD_OFFSET, |
| dimensionCenterOffset = 0; |
| |
| // If we are using bands on the one axis that is shown |
| // update position and offset accordingly. |
| if (area.dimensionsUsingBands.contains(dimensionCol)) { |
| assert(dimensionScale is OrdinalScale); |
| dimensionOffset = (dimensionScale as OrdinalScale).rangeBand ~/ 2; |
| dimensionCenterOffset = dimensionOffset; |
| } |
| |
| var rowData = area.data.rows.elementAt(row), isNegative = false; |
| num measurePosition = 0, |
| dimensionPosition = |
| dimensionScale.scale(rowData.elementAt(dimensionCol)) + |
| dimensionCenterOffset; |
| |
| if (_isMultiValue) { |
| num max = SMALL_INT_MIN, min = SMALL_INT_MAX; |
| area.config.series.forEach((ChartSeries series) { |
| CartesianRenderer renderer = series.renderer; |
| Extent<num> extent = renderer.extentForRow(rowData); |
| if (extent.min < min) min = extent.min; |
| if (extent.max > max) max = extent.max; |
| measurePosition = measureScale.scale(max) as num; |
| isNegative = max < 0.0; |
| }); |
| } else { |
| num value = rowData.elementAt(column); |
| if (value != null) { |
| isNegative = value < 0.0; |
| measurePosition = measureScale.scale(value) as num; |
| } |
| } |
| |
| if (area.config.isLeftAxisPrimary) { |
| _positionAtPoint(measurePosition, dimensionPosition, _HOVERCARD_OFFSET, |
| dimensionOffset, isNegative, true); |
| } else { |
| _positionAtPoint(dimensionPosition, measurePosition, dimensionOffset, |
| _HOVERCARD_OFFSET, isNegative, false); |
| } |
| } |
| |
| void _positionAtPoint(num x, num y, num xBand, num yBand, bool negative, |
| [bool isLeftPrimary = false]) { |
| var rect = _hovercardRoot.getBoundingClientRect(), |
| width = rect.width, |
| height = rect.height, |
| scaleToHostY = |
| (_area.theme.padding != null ? _area.theme.padding.top : 0) + |
| (_area.layout.renderArea.y), |
| scaleToHostX = |
| (_area.theme.padding != null ? _area.theme.padding.start : 0) + |
| (_area.layout.renderArea.x), |
| renderAreaHeight = _area.layout.renderArea.height, |
| renderAreaWidth = _area.layout.renderArea.width; |
| |
| if (scaleToHostY < 0) scaleToHostY = 0; |
| if (scaleToHostX < 0) scaleToHostX = 0; |
| |
| num top = 0, left = 0; |
| for (int i = 0, len = placementOrder.length; i < len; ++i) { |
| String placement = placementOrder.elementAt(i); |
| |
| // Place the popup based on the orientation. |
| if (placement == 'orientation') { |
| placement = isLeftPrimary ? 'right' : 'top'; |
| } |
| |
| if (placement == 'top') { |
| top = negative ? y + yBand : y - (height + yBand); |
| left = isLeftPrimary ? x - width : x - width / 2; |
| } |
| if (placement == 'right') { |
| top = isLeftPrimary ? y - height / 2 : y; |
| left = negative ? x - (width + xBand) : x + xBand; |
| } |
| if (placement == 'left') { |
| top = isLeftPrimary ? y - height / 2 : y; |
| left = negative ? x + xBand : x - (width + xBand); |
| } |
| if (placement == 'bottom') { |
| top = negative ? y - (height + yBand) : y + yBand; |
| left = isLeftPrimary ? x - width : x - width / 2; |
| } |
| |
| // Check if the popup is contained in the RenderArea. |
| // If not, try other placements. |
| if (top >= 0 && |
| left >= 0 && |
| top + height < renderAreaHeight && |
| left + width < renderAreaWidth) { |
| break; |
| } |
| } |
| |
| _hovercardRoot.style |
| ..top = '${top + scaleToHostY}px' |
| ..left = '${left + scaleToHostX}px'; |
| } |
| |
| Element _createTooltip(int column, int row) { |
| var element = new Element.div(); |
| if (_showDimensionTitle) { |
| var titleElement = new Element.div() |
| ..className = 'hovercard-title' |
| ..text = _getDimensionTitle(column, row); |
| element.append(titleElement); |
| } |
| |
| var measureVals = _getMeasuresData(column, row); |
| measureVals.forEach((ChartLegendItem item) { |
| var labelElement = new Element.div() |
| ..className = 'hovercard-measure-label' |
| ..text = item.label, |
| valueElement = new Element.div() |
| ..style.color = item.color |
| ..className = 'hovercard-measure-value' |
| ..text = item.value, |
| measureElement = new Element.div() |
| ..append(labelElement) |
| ..append(valueElement); |
| |
| measureElement.className = _columnsToShow.length > 1 || _isMultiValue |
| ? 'hovercard-measure hovercard-multi' |
| : 'hovercard-measure hovercard-single'; |
| element.append(measureElement); |
| }); |
| |
| return element; |
| } |
| |
| Iterable<ChartLegendItem> _getMeasuresData(int column, int row) { |
| var measureVals = <ChartLegendItem>[]; |
| |
| if (_columnsToShow.isNotEmpty) { |
| _columnsToShow.forEach((int column) { |
| measureVals.add(_createHovercardItem(column, row)); |
| }); |
| } else if (_columnsToShow.length > 1 || _isMultiValue) { |
| var displayedCols = <int>[]; |
| _area.config.series.forEach((ChartSeries series) { |
| series.measures.forEach((int column) { |
| if (!displayedCols.contains(column)) displayedCols.add(column); |
| }); |
| }); |
| displayedCols.sort(); |
| displayedCols.forEach((int column) { |
| measureVals.add(_createHovercardItem(column, row)); |
| }); |
| } else { |
| measureVals.add(_createHovercardItem(column, row)); |
| } |
| |
| return measureVals; |
| } |
| |
| ChartLegendItem _createHovercardItem(int column, int row) { |
| var rowData = _area.data.rows.elementAt(row), |
| columns = _area.data.columns, |
| spec = columns.elementAt(column), |
| colorKey = _area.useRowColoring ? row : column, |
| formatter = _getFormatterForColumn(column), |
| label = _area.useRowColoring |
| ? rowData.elementAt(_area.config.dimensions.first) as String |
| : spec.label; |
| return new ChartLegendItem( |
| label: label, |
| value: formatter(rowData.elementAt(column)), |
| color: _area.theme.getColorForKey(colorKey)); |
| } |
| |
| String _getDimensionTitle(int column, int row) { |
| var rowData = _area.data.rows.elementAt(row), |
| colSpec = _area.data.columns.elementAt(column); |
| if (_area.useRowColoring) { |
| return colSpec.label; |
| } else { |
| var count = (_area as CartesianArea).useTwoDimensionAxes ? 2 : 1, |
| dimensions = _area.config.dimensions.take(count); |
| return dimensions |
| .map((int c) => _getFormatterForColumn(c)(rowData.elementAt(c))) |
| .join(', '); |
| } |
| } |
| |
| // TODO: Move this to a common place? |
| Scale _getScaleForColumn(int column) { |
| var series = _area.config.series.firstWhere( |
| (ChartSeries x) => x.measures.contains(column), |
| orElse: () => null); |
| return series != null |
| ? (_area as CartesianArea).measureScales(series).first |
| : null; |
| } |
| |
| // TODO: Move this to a common place? |
| FormatFunction _getFormatterForColumn(int column) { |
| var formatter = _area.data.columns.elementAt(column).formatter; |
| if (formatter == null && _area is CartesianArea) { |
| var scale = _getScaleForColumn(column); |
| if (scale != null) { |
| formatter = scale.createTickFormatter(); |
| } |
| } |
| if (formatter == null) { |
| // Formatter function must return String. Default to identity function |
| // but return the toString() instead. |
| formatter = (x) => x.toString(); |
| } |
| return formatter; |
| } |
| } |