veritatisquam
Version:
Carbon charting components, lerna monorepo
641 lines (575 loc) • 17.7 kB
text/typescript
// 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)
);
}
}