veritatisquam
Version:
Carbon charting components, lerna monorepo
273 lines (234 loc) • 7.84 kB
text/typescript
// Internal Imports
import { Component } from '../component';
import { DOMUtils } from '../../services';
import { CartesianOrientations, Events } from '../../interfaces';
import { Tools } from '../../tools';
// D3 Imports
import { Selection, mouse } from 'd3-selection';
type GenericSvgSelection = Selection<SVGElement, any, SVGElement, any>;
const THRESHOLD = 5;
/** check if x is inside threshold area extents */
function pointIsWithinThreshold(dx: number, x: number) {
return dx > x - THRESHOLD && dx < x + THRESHOLD;
}
export class Ruler extends Component {
type = 'ruler';
backdrop: GenericSvgSelection;
elementsToHighlight: GenericSvgSelection;
pointsWithinLine: {
domainValue: number;
originalData: any;
}[];
isXGridEnabled = Tools.getProperty(
this.getOptions(),
'grid',
'x',
'enabled'
);
isYGridEnabled = Tools.getProperty(
this.getOptions(),
'grid',
'y',
'enabled'
);
// flag for checking whether ruler event listener is added or not
isEventListenerAdded = false;
render() {
const isRulerEnabled = Tools.getProperty(
this.getOptions(),
'ruler',
'enabled'
);
this.drawBackdrop();
if (isRulerEnabled && !this.isEventListenerAdded) {
this.addBackdropEventListeners();
} else if (!isRulerEnabled && this.isEventListenerAdded) {
this.removeBackdropEventListeners();
}
}
removeBackdropEventListeners() {
this.isEventListenerAdded = false;
this.backdrop.on('mousemove mouseover mouseout', null);
}
formatTooltipData(tooltipData) {
return tooltipData;
}
showRuler([x, y]: [number, number]) {
const svg = this.parent;
const orientation: CartesianOrientations = this.services.cartesianScales.getOrientation();
const mouseCoordinate =
orientation === CartesianOrientations.HORIZONTAL ? y : x;
const ruler = DOMUtils.appendOrSelect(svg, 'g.ruler').attr(
'aria-label',
'ruler'
);
const rulerLine = DOMUtils.appendOrSelect(ruler, 'line.ruler-line');
const dataPointElements: GenericSvgSelection = svg.selectAll(
'[role=graphics-symbol]'
);
const displayData = this.model.getDisplayData();
const rangeScale = this.services.cartesianScales.getRangeScale();
const [yScaleEnd, yScaleStart] = rangeScale.range();
const pointsWithinLine = displayData
.map((d) => ({
domainValue: this.services.cartesianScales.getDomainValue(d),
originalData: d,
}))
.filter((d) =>
pointIsWithinThreshold(d.domainValue, mouseCoordinate)
);
if (
this.pointsWithinLine &&
pointsWithinLine.length === this.pointsWithinLine.length &&
pointsWithinLine.map((point) => point.domainValue).join() ===
this.pointsWithinLine.map((point) => point.domainValue).join()
) {
this.pointsWithinLine = pointsWithinLine;
return this.services.events.dispatchEvent(Events.Tooltip.MOVE, {
mousePosition: [x, y],
});
}
this.pointsWithinLine = pointsWithinLine;
/**
* Find matches, reduce is used instead of filter
* to only get elements which belong to the same axis coordinate
*/
const dataPointsMatchingRulerLine: {
domainValue: number;
originalData: any;
}[] = this.pointsWithinLine.reduce((accum, currentValue) => {
if (accum.length === 0) {
accum.push(currentValue);
return accum;
}
// store the first element of the accumulator array to compare it with current element being processed
const sampleAccumValue = accum[0].domainValue;
const distanceToCurrentValue = Math.abs(
mouseCoordinate - currentValue.domainValue
);
const distanceToAccumValue = Math.abs(
mouseCoordinate - sampleAccumValue
);
if (distanceToCurrentValue > distanceToAccumValue) {
// if distance with current value is bigger than already existing value in the accumulator, skip current iteration
return accum;
} else if (distanceToCurrentValue < distanceToAccumValue) {
// currentValue data point is closer to mouse inside the threshold area, so reinstantiate array
accum = [currentValue];
} else {
// currentValue is equal to already stored values, which means there's another match on the same coordinate
accum.push(currentValue);
}
return accum;
}, []);
// some data point match
if (dataPointsMatchingRulerLine.length > 0) {
const tooltipData = dataPointsMatchingRulerLine
.map((d) => d.originalData)
.filter((d) => {
const rangeIdentifier = this.services.cartesianScales.getRangeIdentifier(
d
);
const value = d[rangeIdentifier];
return value !== null && value !== undefined;
});
// get elements on which we should trigger mouse events
const domainValuesMatchingRulerLine = dataPointsMatchingRulerLine.map(
(d) => d.domainValue
);
const elementsToHighlight = dataPointElements.filter((d) => {
const domainValue = this.services.cartesianScales.getDomainValue(
d
);
return domainValuesMatchingRulerLine.includes(domainValue);
});
/** if we pass from a trigger area to another one
* mouseout on previous elements won't get dispatched
* so we need to do it manually
*/
if (
this.elementsToHighlight &&
this.elementsToHighlight.size() > 0 &&
!Tools.isEqual(this.elementsToHighlight, elementsToHighlight)
) {
this.hideRuler();
}
elementsToHighlight.dispatch('mouseover');
// set current hovered elements
this.elementsToHighlight = elementsToHighlight;
this.services.events.dispatchEvent(Events.Tooltip.SHOW, {
mousePosition: [x, y],
hoveredElement: rulerLine,
data: this.formatTooltipData(tooltipData),
});
ruler.attr('opacity', 1);
// line snaps to matching point
const sampleMatch = dataPointsMatchingRulerLine[0];
if (orientation === 'horizontal') {
rulerLine
.attr('x1', yScaleStart)
.attr('x2', yScaleEnd)
.attr('y1', sampleMatch.domainValue)
.attr('y2', sampleMatch.domainValue);
} else {
rulerLine
.attr('y1', yScaleStart)
.attr('y2', yScaleEnd)
.attr('x1', sampleMatch.domainValue)
.attr('x2', sampleMatch.domainValue);
}
} else {
this.hideRuler();
}
}
hideRuler() {
const svg = this.parent;
const ruler = DOMUtils.appendOrSelect(svg, 'g.ruler');
const dataPointElements = svg.selectAll('[role=graphics-symbol]');
dataPointElements.dispatch('mouseout');
this.services.events.dispatchEvent(Events.Tooltip.HIDE);
ruler.attr('opacity', 0);
}
/**
* Adds the listener on the X grid to trigger multiple point tooltips along the x axis.
*/
addBackdropEventListeners() {
this.isEventListenerAdded = true;
const self = this;
const displayData = this.model.getDisplayData();
let mouseMoveCallback = function () {
const pos = mouse(self.parent.node());
self.showRuler(pos);
};
// Debounce mouseMoveCallback if there are more than 100 datapoints
if (displayData.length > 100) {
const debounceThreshold = (displayData.length % 50) * 12.5;
mouseMoveCallback = Tools.debounceWithD3MousePosition(
function () {
const { mousePosition } = this;
self.showRuler(mousePosition);
},
debounceThreshold,
this.parent.node()
);
}
this.backdrop
.on('mousemove mouseover', mouseMoveCallback)
.on('mouseout', this.hideRuler.bind(this));
}
drawBackdrop() {
const svg = this.parent;
const mainXScale = this.services.cartesianScales.getMainXScale();
const mainYScale = this.services.cartesianScales.getMainYScale();
const [xScaleStart, xScaleEnd] = mainXScale.range();
const [yScaleEnd, yScaleStart] = mainYScale.range();
// Get height from the grid
this.backdrop = DOMUtils.appendOrSelect(svg, 'svg.chart-grid-backdrop');
const backdropRect = DOMUtils.appendOrSelect(
this.backdrop,
this.isXGridEnabled || this.isYGridEnabled
? 'rect.chart-grid-backdrop.stroked'
: 'rect.chart-grid-backdrop'
);
}
}