UNPKG

veritatisquam

Version:
641 lines (575 loc) 17.7 kB
// Internal Imports import { Component } from '../component'; import { ChartModelCartesian } from '../../model-cartesian-charts'; import { Tools } from '../../tools'; import { AxisPositions, Events, ScaleTypes, ZoomBarTypes, } from '../../interfaces'; import { DOMUtils } from '../../services'; import * as Configuration from '../../configuration'; // D3 Imports import { extent } from 'd3-array'; import { brushX } from 'd3-brush'; import { area, line } from 'd3-shape'; import { event } from 'd3-selection'; export class ZoomBar extends Component { type = 'zoom-bar'; // The minimum selection x range to trigger handler update // Smaller number may introduce a handler flash during initialization // Bigger number may not trigger handler update while selection area on chart is very small MIN_SELECTION_DIFF = 9e-10; // needs to match the style in _zoom-bar.scss brushSelector = 'g.zoom-bar-brush'; // The max allowed selection range, will be updated soon in render() maxSelectionRange: [0, 0]; // Give every zoomBarClip a distinct ID // so they don't interfere the other zoom bars in a page clipId = 'zoomBarClip-' + Math.floor(Math.random() * 99999999999); brush = brushX(); xScale: any; yScale: any; protected model: ChartModelCartesian; init() { this.services.events.addEventListener( Events.ZoomBar.UPDATE, this.render.bind(this) ); // check if pre-defined zoom bar data exists const definedZoomBarData = Tools.getProperty( this.getOptions(), 'zoomBar', AxisPositions.TOP, 'data' ); // load up the zoomBarData into this model this.model.setZoomBarData(definedZoomBarData); } render(animate = true) { const svg = this.getContainerSVG(); const isTopZoomBarLoading = this.services.zoom.isZoomBarLoading( AxisPositions.TOP ); const isTopZoomBarLocked = this.services.zoom.isZoomBarLocked( AxisPositions.TOP ); const zoombarType = Tools.getProperty( this.getOptions(), 'zoomBar', AxisPositions.TOP, 'type' ); const zoombarHeight = Configuration.zoomBar.height[zoombarType]; const { width } = DOMUtils.getSVGElementSize(this.parent, { useAttrs: true, }); // initialization is not completed yet if (width === 0) { return; } // get axes margins let axesLeftMargin = 0; const axesMargins = this.model.get('axesMargins'); if (axesMargins && axesMargins.left) { axesLeftMargin = axesMargins.left; } const container = DOMUtils.appendOrSelect(svg, 'svg.zoom-container') .attr('width', '100%') .attr('height', zoombarHeight) .attr('opacity', 1); const spacer = DOMUtils.appendOrSelect(svg, 'rect.zoom-spacer') .attr('x', 0) .attr('y', zoombarHeight) .attr('width', '100%') .attr('height', Configuration.zoomBar.spacerHeight) .attr('opacity', 1) .attr('fill', 'none'); if (zoombarType === ZoomBarTypes.GRAPH_VIEW) { // Draw zoombar background rectangle DOMUtils.appendOrSelect(container, 'rect.zoom-bg') .attr('x', axesLeftMargin) .attr('y', 0) .attr('width', width - axesLeftMargin) .attr('height', '100%') .classed('zoom-bg-skeleton', isTopZoomBarLoading); } else if (zoombarType === ZoomBarTypes.SLIDER_VIEW) { // Draw zoombar background line DOMUtils.appendOrSelect(container, 'rect.zoom-slider-bg') .attr('x', axesLeftMargin) .attr('y', zoombarHeight / 2 - 1) .attr('width', width - axesLeftMargin) .attr('height', 2) .classed('zoom-slider-bg-skeleton', isTopZoomBarLoading); } if (isTopZoomBarLoading) { this.renderSkeleton(container, axesLeftMargin, width); return; } const { cartesianScales } = this.services; const mainXScale = cartesianScales.getMainXScale(); const mainYScale = cartesianScales.getMainYScale(); const mainXScaleType = cartesianScales.getMainXScaleType(); if (mainXScale && mainXScaleType === ScaleTypes.TIME) { let zoomBarData = this.services.zoom.getZoomBarData(); if (Tools.isEmpty(zoomBarData)) { // if there's no zoom bar data we can't do anything return; } this.xScale = mainXScale.copy(); this.yScale = mainYScale.copy(); const defaultDomain = this.services.zoom.getDefaultZoomBarDomain( zoomBarData ); // add value 0 to the extended domain for zoom bar area graph zoomBarData = this.compensateDataForDefaultDomain( zoomBarData, defaultDomain ); // get old initialZoomDomain from model const oldInitialZoomDomain = this.model.get('initialZoomDomain'); // get new initialZoomDomain from option const newInitialZoomDomain = Tools.getProperty( this.getOptions(), 'zoomBar', AxisPositions.TOP, 'initialZoomDomain' ); // change string date to Date object if necessary if ( newInitialZoomDomain && newInitialZoomDomain[0] && newInitialZoomDomain[1] ) { newInitialZoomDomain[0] = new Date(newInitialZoomDomain[0]); newInitialZoomDomain[1] = new Date(newInitialZoomDomain[1]); } // update initialZoomDomain and set zoomDomain in model only if the option is changed // not the same object, and both start date and end date are not equal if ( newInitialZoomDomain && !( oldInitialZoomDomain && oldInitialZoomDomain[0].valueOf() === newInitialZoomDomain[0].valueOf() && oldInitialZoomDomain[1].valueOf() === newInitialZoomDomain[1].valueOf() ) ) { this.model.set( { // use a new object instead of newInitialZoomDomain initialZoomDomain: Tools.merge( [], newInitialZoomDomain ), zoomDomain: newInitialZoomDomain ? Tools.merge([], newInitialZoomDomain) : defaultDomain }, { skipUpdate: true } ); } this.xScale.range([axesLeftMargin, width]).domain(defaultDomain); // keep max selection range this.maxSelectionRange = this.xScale.range(); this.yScale .range([0, zoombarHeight - 6]) .domain(extent(zoomBarData, (d: any) => d.value)); const zoomDomain = this.model.get('zoomDomain'); if (zoombarType === ZoomBarTypes.GRAPH_VIEW) { this.renderZoomBarArea( container, 'path.zoom-graph-area-unselected', zoomBarData, null ); this.updateClipPath(svg, this.clipId, 0, 0, 0, 0); this.renderZoomBarArea( container, 'path.zoom-graph-area', zoomBarData, this.clipId ); // Draw the zoom bar base line this.renderZoomBarBaseline(container, axesLeftMargin, width); } // Attach brushing event listeners this.addBrushEventListener(zoomDomain, axesLeftMargin, width); // Draw the brushing area const brushArea = DOMUtils.appendOrSelect( svg, this.brushSelector ).call(this.brush); if (zoomDomain === undefined) { // do nothing, initialization not completed yet // don't update brushHandle to avoid flash } else if (zoomDomain[0].valueOf() === zoomDomain[1].valueOf()) { brushArea.call(this.brush.move, this.xScale.range()); // default to full range this.updateBrushHandle( this.getContainerSVG(), this.xScale.range(), this.xScale.domain() ); } else { const selected = zoomDomain.map((domain) => this.xScale(domain) ); if (selected[1] - selected[0] < this.MIN_SELECTION_DIFF) { // initialization not completed yet // don't update brushHandle to avoid flash } else { brushArea.call(this.brush.move, selected); // set brush to correct position this.updateBrushHandle( this.getContainerSVG(), selected, zoomDomain ); } } if (isTopZoomBarLocked) { this.brush.filter(() => { return false; }); // reset all cursor to auto brushArea.selectAll('rect').attr('cursor', 'auto'); } } } addBrushEventListener(zoomDomain, axesLeftMargin, width) { const brushEventListener = () => { const selection = event.selection; // follow d3 behavior: when selection is null, reset default full range // select behavior is completed, but nothing selected if (selection === null) { this.handleBrushedEvent( zoomDomain, this.xScale, this.xScale.range() ); } else if (selection[0] === selection[1]) { // select behavior is not completed yet, do nothing } else { this.handleBrushedEvent(zoomDomain, this.xScale, selection); } }; const zoombarType = Tools.getProperty( this.getOptions(), 'zoomBar', AxisPositions.TOP, 'type' ); const zoombarHeight = Configuration.zoomBar.height[zoombarType]; // Initialize the d3 brush this.brush .extent([ [axesLeftMargin, 0], [width, zoombarHeight], ]) .on('start brush end', null) // remove old listener first .on('start brush end', brushEventListener); } // brush event listener handleBrushedEvent(zoomDomain, scale, selection) { const newDomain = [ scale.invert(selection[0]), scale.invert(selection[1]), ]; // update brush handle position this.updateBrushHandle(this.getContainerSVG(), selection, newDomain); // be aware that the value of d3.event changes during an event! // update zoomDomain only if the event comes from mouse/touch event if ( event.sourceEvent != null && (event.sourceEvent.type === 'mousemove' || event.sourceEvent.type === 'mouseup' || event.sourceEvent.type === 'mousedown' || event.sourceEvent.type === 'touchstart' || event.sourceEvent.type === 'touchmove' || event.sourceEvent.type === 'touchend') ) { // only if zoomDomain is never set or needs update if ( zoomDomain === undefined || zoomDomain[0] !== newDomain[0] || zoomDomain[1] !== newDomain[1] ) { // don't dispatch event for all event types // let the following code to dispatch necessary events this.services.zoom.handleDomainChange(newDomain, { dispatchEvent: false, }); } // dispatch selection events let zoomBarEventType; if (event.type === 'start') { zoomBarEventType = Events.ZoomBar.SELECTION_START; } else if (event.type === 'brush') { zoomBarEventType = Events.ZoomBar.SELECTION_IN_PROGRESS; } else if (event.type === 'end') { zoomBarEventType = Events.ZoomBar.SELECTION_END; // only dispatch zoom domain change event for triggering api call when event type equals to end this.services.events.dispatchEvent(Events.ZoomDomain.CHANGE, { newDomain, }); } this.services.events.dispatchEvent(zoomBarEventType, { selection, newDomain, }); } } updateBrushHandle(svg, selection, domain) { const self = this; const handleWidth = Configuration.zoomBar.handleWidth; const zoombarType = Tools.getProperty( this.getOptions(), 'zoomBar', AxisPositions.TOP, 'type' ); const handleHeight = Configuration.zoomBar.height[zoombarType]; const handleXDiff = -handleWidth / 2; const handleBarWidth = Configuration.zoomBar.handleBarWidth; const handleBarHeight = zoombarType === ZoomBarTypes.GRAPH_VIEW ? Configuration.zoomBar.handleBarHeight : 6; const handleBarXDiff = -handleBarWidth / 2; const handleYBarDiff = (handleHeight - handleBarHeight) / 2; // handle svg.select(this.brushSelector) .selectAll('rect.handle') .data([{ type: 'w' }, { type: 'e' }]) .attr('x', function (d) { if (d.type === 'w') { // handle should not exceed zoom bar range return Math.max( selection[0] + handleXDiff, self.maxSelectionRange[0] ); } else if (d.type === 'e') { // handle should not exceed zoom bar range return Math.min( selection[1] + handleXDiff, self.maxSelectionRange[1] - handleWidth ); } }) .attr('y', 0) .attr('width', handleWidth) .attr('height', handleHeight) .attr('cursor', 'ew-resize') .style('display', null); // always display // handle-bar const handleBars = svg .select(this.brushSelector) .selectAll('rect.handle-bar') .data([{ type: 'w' }, { type: 'e' }]); // create rect if not exists handleBars .enter() .append('rect') .attr('class', function (d) { return 'handle-bar handle-bar--' + d.type; }); // update positions handleBars .attr('x', function (d) { if (d.type === 'w') { return Math.max( selection[0] + handleBarXDiff, self.maxSelectionRange[0] - handleXDiff + handleBarXDiff ); } else if (d.type === 'e') { return Math.min( selection[1] + handleBarXDiff, self.maxSelectionRange[1] + handleXDiff + handleBarXDiff ); } }) .attr('y', handleYBarDiff) .attr('width', handleBarWidth) .attr('height', handleBarHeight) .attr('cursor', 'ew-resize'); // Update slider selected area if (zoombarType === ZoomBarTypes.SLIDER_VIEW) { this.updateSliderSelectedArea(selection); } this.updateClipPath( svg, this.clipId, selection[0], 0, selection[1] - selection[0], handleHeight ); } updateSliderSelectedArea(selection) { const zoombarType = Tools.getProperty( this.getOptions(), 'zoomBar', AxisPositions.TOP, 'type' ); const zoombarHeight = Configuration.zoomBar.height[zoombarType]; const { width } = DOMUtils.getSVGElementSize(this.parent, { useAttrs: true, }); // get axes margins let axesLeftMargin = 0; const axesMargins = this.model.get('axesMargins'); if (axesMargins && axesMargins.left) { axesLeftMargin = axesMargins.left; } const svg = this.getContainerSVG(); const container = svg.select('svg.zoom-container'); // Draw zoombar background line DOMUtils.appendOrSelect(container, 'rect.zoom-slider-selected-area') .attr('x', selection[0]) .attr('y', zoombarHeight / 2 - 1) .attr('width', selection[1] - selection[0]) .attr('height', 2); } renderZoomBarArea(container, querySelector, data, clipId) { const { cartesianScales } = this.services; const mainXAxisPosition = cartesianScales.getMainXAxisPosition(); const mainYAxisPosition = cartesianScales.getMainYAxisPosition(); const mainXScaleType = cartesianScales.getMainXScaleType(); const mainYScaleType = cartesianScales.getMainYScaleType(); const accessorFunction = (scale, scaleType, axisPosition) => { return (d, i) => { return cartesianScales.getValueFromScale( scale, scaleType, axisPosition, d, i ); }; }; const xAccessor = accessorFunction( this.xScale, mainXScaleType, mainXAxisPosition ); const yAccessor = accessorFunction( this.yScale, mainYScaleType, mainYAxisPosition ); const zoombarType = Tools.getProperty( this.getOptions(), 'zoomBar', AxisPositions.TOP, 'type' ); const zoombarHeight = Configuration.zoomBar.height[zoombarType]; const areaGenerator = area() .x((d, i) => xAccessor(d, i)) .y0(zoombarHeight) .y1((d, i) => zoombarHeight - yAccessor(d, i)); const areaGraph = DOMUtils.appendOrSelect(container, querySelector) .datum(data) .attr('d', areaGenerator); if (clipId) { areaGraph.attr('clip-path', `url(#${clipId})`); } } updateClipPath(svg, clipId, x, y, width, height) { const zoomBarClipPath = DOMUtils.appendOrSelect(svg, `clipPath`).attr( 'id', clipId ); DOMUtils.appendOrSelect(zoomBarClipPath, 'rect') .attr('x', x) .attr('y', y) .attr('width', width) .attr('height', height); } // assume the domains in data are already sorted compensateDataForDefaultDomain(data, defaultDomain) { if (!data || data.length < 2) { return; } const zoomBarData = Tools.clone(data); const domainIdentifier = this.services.cartesianScales.getDomainIdentifier(); const rangeIdentifier = this.services.cartesianScales.getRangeIdentifier(); // if min domain is extended if ( Number(defaultDomain[0]) < Number(zoomBarData[0][domainIdentifier]) ) { const newDatum = {}; newDatum[domainIdentifier] = defaultDomain[0]; newDatum[rangeIdentifier] = 0; zoomBarData.unshift(newDatum); } // if max domain is extended if ( Number(defaultDomain[1]) > Number(zoomBarData[zoomBarData.length - 1][domainIdentifier]) ) { const newDatum = {}; newDatum[domainIdentifier] = defaultDomain[1]; newDatum[rangeIdentifier] = 0; zoomBarData.push(newDatum); } return zoomBarData; } renderZoomBarBaseline(container, startX, endX, skeletonClass = false) { const zoombarType = Tools.getProperty( this.model.getOptions(), 'zoomBar', AxisPositions.TOP, 'type' ); const zoombarHeight = Configuration.zoomBar.height[zoombarType]; const baselineGenerator = line()([ [startX, zoombarHeight], [endX, zoombarHeight], ]); DOMUtils.appendOrSelect(container, 'path.zoom-bg-baseline') .attr('d', baselineGenerator) .classed('zoom-bg-baseline-skeleton', skeletonClass); } renderSkeleton(container, startX, endX) { // need to clear current zoom bar area this.renderZoomBarArea( container, 'path.zoom-graph-area-unselected', [], null ); this.renderZoomBarArea( container, 'path.zoom-graph-area', [], this.clipId ); // remove brush listener this.brush.on('start brush end', null); // clear d3 brush DOMUtils.appendOrSelect( this.getContainerSVG(), this.brushSelector ).html(null); // re-render baseline because no axis labels in skeleton so the baseline length needs to change const zoombarType = Tools.getProperty( this.getOptions(), 'zoomBar', AxisPositions.TOP, 'type' ); if (zoombarType === ZoomBarTypes.GRAPH_VIEW) { this.renderZoomBarBaseline(container, startX, endX, true); } } destroy() { this.brush.on('start brush end', null); // remove event listener this.services.events.removeEventListener( Events.ZoomBar.UPDATE, this.render.bind(this) ); } }