blob: a12951c8380a730c6a20616e6b9a356cf82ad207 [file] [log] [blame]
//
// 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;
}
}