lucid-ui
Version:
A UI component library from AppNexus.
402 lines (401 loc) • 16.5 kB
JavaScript
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;