UNPKG

veritatisquam

Version:
273 lines (234 loc) 7.84 kB
// 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' ); } }