UNPKG

lucid-ui

Version:

A UI component library from AppNexus.

402 lines (401 loc) 16.5 kB
import * as d3Axis from 'd3-axis'; import * as d3Shape from 'd3-shape'; import * as d3Drag from 'd3-drag'; import * as d3Selection from 'd3-selection'; import * as d3Transition from 'd3-transition'; import ReactDOM from 'react-dom'; import { d3Scale } from '../../index'; import _ from 'lodash'; import * as d3Array from 'd3-array'; import { lucidClassNames } from '../../util/style-helpers'; import { getGroup, lucidXAxis, } from './d3-helpers'; const cx = lucidClassNames.bind('&-DraggableLineChart'); const getAttributes = function (selection, obj) { return _.reduce(obj, (acc, value) => { // @ts-ignore acc[value] = selection.attr(value); return acc; }, {}); }; class DraggableLineChartD3 { constructor(selection, params) { this.setMouseDown = (isMouseDown, mouseDownStep) => { this.params.isMouseDown = isMouseDown; this.params.mouseDownStep = mouseDownStep; }; this.getIsMouseDown = () => { return this.params.isMouseDown; }; this.getMouseDownStep = () => { return this.params.mouseDownStep || 0; }; this.getHasRenderedPoint = () => { return !!this.params.hasRenderedPoint; }; this.getHasRenderedLine = () => { return !!this.params.hasRenderedLine; }; this.setHasRenderedPoint = () => { this.params.hasRenderedPoint = true; }; this.setHasRenderedLine = () => { this.params.hasRenderedLine = true; }; this.shouldShowPreselect = () => { const hasUserValues = _.some(this.params.data, ({ y }) => y > 0); return !!this.params.onPreselect && !hasUserValues; }; this.drag = () => { const { xScale, yScale, renderLine, renderPoints, selection } = this; const { cx, onDragEnd } = this.params; let initialPosition; return d3Drag .drag() .on('start', function () { const activeDot = d3Selection.select(this); initialPosition = Number(activeDot.attr('cy')); }) .on('drag', function (pointData) { const [max, min] = yScale.range(); const activeDot = d3Selection.select(this); const adjMouseY = initialPosition + d3Selection.event.y; const newPointY = adjMouseY < min ? min : adjMouseY > max ? max : adjMouseY; const lines = selection.selectAll(`path.${cx('&-Line')}`); pointData.y = Number(yScale.invert(newPointY)); activeDot.attr('cy', newPointY); const line = d3Shape .line() .x((chartData) => xScale(chartData.x) || 0) .y((chartData) => yScale(chartData.y)); lines.attr('d', line); }) .on('end', (d) => { if (onDragEnd) onDragEnd(d.y, d.x); renderLine(); renderPoints(); }); }; this.renderXAxis = () => { const { margin, height, xAxisTicksVertical, dataIsCentered, cx, xAxisRenderProp, data, } = this.params; const xGroup = getGroup(this.selection, `${cx('&-Axis')}`); xGroup .call((xAxis) => { xAxis .attr('transform', `translate(${0},${margin.top})`) .call(lucidXAxis, { xScale: this.xScale, tickSize: margin.top + margin.bottom - height, xAxisRenderProp, dataIsCentered, data, }); if (xAxisTicksVertical) { xAxis.classed('Vert', true); } else { xAxis.classed('NoVert', true); } if (dataIsCentered) { xAxis.classed('Center', true); } }) .call(() => xGroup); }; this.renderYAxis = () => { const yGroup = getGroup(this.selection, 'yAxisGroup'); yGroup .call((yAxis) => { const { margin, cx } = this.params; yAxis .attr('transform', `translate(${margin.left},${0})`) .classed(`${cx('&-Axis')}`, true) .transition() .duration(500) .call(d3Axis.axisLeft(this.yScale).ticks(10)); }) .call(() => yGroup); }; this.renderLine = () => { if (this.shouldShowPreselect()) { return; } const { dataIsCentered, cx } = this.params; if (!this.getHasRenderedLine()) { if (dataIsCentered) { const innerXTickWidth = this.xScale.step(); this.selection .append('g') .append('path') .attr('class', `${cx('&-Line')}`) .attr('transform', `translate(${innerXTickWidth / 2}, 0)`); } else { this.selection .append('g') .append('path') .attr('class', `${cx('&-Line')}`); } this.setHasRenderedLine(); } const lines = this.selection.selectAll(`path.${cx('&-Line')}`); lines.datum(this.params.data).enter(); lines .transition(d3Transition.transition().duration(100)) .attr('fill', 'none') .attr('d', d3Shape .line() .x((d) => this.xScale(d.x) || 0) .y((d) => this.yScale(d.y))); }; this.renderEmptyRenderProp = (height, width) => { const { emptyRenderProp } = this.params; if (!emptyRenderProp || !this.shouldShowPreselect()) { return; } const emptyDataObject = this.selection.selectAll('.emptyRender'); if (emptyDataObject.empty()) { const emptyRender = this.selection .selectAll('.overlayContainer') .append('foreignObject') .attr('height', height) .attr('width', width) .attr('x', this.params.margin.left) .classed('emptyRender', true); emptyRender.html((value, num, node) => { ReactDOM.render(emptyRenderProp(), node[0]); }); } }; this.renderPoints = () => { if (this.shouldShowPreselect()) { return; } const { data, dataIsCentered } = this.params; const circle = this.getHasRenderedPoint() ? this.selection .selectAll('circle') .data(data) .join('circle') : this.selection .append('g') .selectAll('circle') .data(data) .join('circle'); if (dataIsCentered) { const innerXTickWidth = this.xScale.step(); circle .transition() .duration(100) .attr('cx', (d) => this.xScale(d.x) || 0) .attr('cy', (d) => this.yScale(d.y)) .attr('r', 5) .attr('transform', `translate(${innerXTickWidth / 2}, 0)`) .style('fill', '#587EBA') .style('stroke', 'white') .style('stroke-width', 1); } else { circle .transition() .duration(100) .attr('cx', (d) => this.xScale(d.x) || 0) .attr('cy', (d) => this.yScale(d.y)) .attr('r', 5) .style('fill', '#587EBA') .style('stroke', 'white') .style('stroke-width', 1); } if (!this.getHasRenderedPoint()) circle.call(this.drag()); this.setHasRenderedPoint(); }; this.reRenderDragBox = ({ dragBox, mouseX, xLeft, xRight, stepWidth, stepCount, }) => { const isLeft = xLeft >= mouseX; const isRight = xRight <= mouseX; const mouseDownStep = this.getMouseDownStep(); if (isLeft) { const difference = _.max([xLeft - mouseX, 0]) || 0; const rawStepsSelected = Math.floor(difference / stepWidth) + 2; const maxStepsAvailable = mouseDownStep + 1; const stepsSelected = _.min([rawStepsSelected, maxStepsAvailable]) || 1; const activeBoxWidth = stepsSelected * stepWidth; const nextXLeft = xRight - activeBoxWidth; dragBox.attr('x', nextXLeft); dragBox.attr('width', activeBoxWidth); } else if (isRight) { const difference = _.max([mouseX - xRight, 0]) || 0; const rawStepsSelected = Math.floor(difference / stepWidth) + 2; const maxStepsAvailable = stepCount - mouseDownStep; const stepsSelected = _.min([rawStepsSelected, maxStepsAvailable]) || 1; const activeBoxWidth = stepsSelected * stepWidth; dragBox.attr('x', xLeft); dragBox.attr('width', activeBoxWidth); } else { dragBox.attr('x', xLeft); dragBox.attr('width', stepWidth); } }; this.renderHoverTracker = () => { const { height, margin: { top, bottom }, data, onPreselect, } = this.params; const { shouldShowPreselect, setMouseDown, getIsMouseDown, getMouseDownStep, reRenderDragBox, xScale, selection, } = this; if (!shouldShowPreselect()) { selection.selectAll('.overlayContainer').remove(); return; } const innerHeight = height - top - bottom; const stepWidth = xScale.step(); const stepCount = data.length; const overlayContainer = selection .append('g') .classed('overlayContainer', true) .attr('transform', `translate(${0},${top})`); this.renderEmptyRenderProp(innerHeight, stepCount * stepWidth); const overlayTrack = overlayContainer .selectAll('rect') .data(data) .enter(); overlayTrack .append('rect') .attr('x', chartData => this.xScale(chartData.x) || 0) .attr('y', 0) .attr('width', chartData => this.xScale.step()) .attr('height', innerHeight) .classed(cx('&-overlayTrack'), true) .on('mouseenter', (d, i, nodes) => { if (!getIsMouseDown()) { d3Selection.select(nodes[i]).classed('active', true); } }) .on('mouseout', function (d, i, nodes) { if (!getIsMouseDown()) { d3Selection.select(nodes[i]).classed('active', false); } }) .on('mousedown', function (d, i) { d3Selection.selectAll('.active').classed('active', false); const currentTarget = d3Selection.select(this); const { x, y, width, height } = getAttributes(currentTarget, [ 'x', 'y', 'width', 'height', ]); setMouseDown(true, i); const xLeft = +x; const xRight = +x + +width; // @ts-ignore const container = d3Selection.select(this.parentNode); container .append('rect') .attr('x', x) .attr('y', y) .attr('width', width) .attr('height', height) .classed(cx('&-overlayTrack'), true) .classed('active', true) .classed('dragBox', true) .on('mouseout', function () { // @ts-ignore const [mouseX] = d3Selection.mouse(this); const dragBox = selection.selectAll('.dragBox'); reRenderDragBox({ dragBox, mouseX, xLeft, xRight, stepWidth, stepCount, }); }) .on('mousemove', function () { // @ts-ignore const [mouseX] = d3Selection.mouse(this); const dragBox = selection.selectAll('.dragBox'); reRenderDragBox({ dragBox, mouseX, xLeft, xRight, stepWidth, stepCount, }); }) .on('mouseup', function () { const clickStep = getMouseDownStep(); const activeBox = selection.selectAll('.dragBox'); const { x, width } = getAttributes(activeBox, ['x', 'width']); const isRight = xLeft === +x; const steps = Math.round(+width / stepWidth); const startingIndex = isRight ? clickStep : clickStep - steps + 1; const endingIndex = startingIndex + steps - 1; const updatedData = data.map((step, i) => ({ ...step, isSelected: i >= startingIndex && i <= endingIndex, })); !!onPreselect && onPreselect(updatedData); setMouseDown(false); selection.selectAll('.dragBox').remove(); selection.selectAll('.overlayContainer').remove(); }); }); }; this.renderLineChart = () => { this.renderXAxis(); this.renderYAxis(); this.renderHoverTracker(); this.renderLine(); this.renderPoints(); }; this.updateLineChart = (data) => { this.params.data = data; this.yScale.domain([ _.isUndefined(this.params.yAxisMin) ? d3Array.min(this.params.data, (d) => d.y) : this.params.yAxisMin, d3Array.max(this.params.data, (d) => d.y) || 10, ]); this.renderLineChart(); }; this.selection = selection; this.params = params; if (params.dataIsCentered) { this.xScale = d3Scale .scalePoint() .domain([...this.params.data.map((d) => d.x), '']) .range([ this.params.margin.left, this.params.width - this.params.margin.right - this.params.margin.left, ]); } else { this.xScale = d3Scale .scalePoint() .domain(this.params.data.map((d) => d.x)) .range([ this.params.margin.left, this.params.width - this.params.margin.right - this.params.margin.left, ]); } this.yScale = d3Scale .scaleLinear() .domain([ _.isUndefined(this.params.yAxisMin) ? d3Array.min(this.params.data, (d) => d.y) : this.params.yAxisMin, d3Array.max(this.params.data, (d) => d.y) || 10, ]) .nice() .range([ this.params.height - this.params.margin.bottom, this.params.margin.top, ]); } } export default DraggableLineChartD3;