class LineAreaChart {
getState() {
return this.state;
}
setState(d) {
return Object.assign(this.state, d)
};
createId() {
return Date.now().toString(36) + Math.random().toString(36).substr(2);
}
constructor() {
document.getElementsByTagName("head")[0].insertAdjacentHTML(
"beforeend",
`<style>.d3-tip tr{border-bottom:none}.d3-tip{font-family:Arial,Helvetica,sans-serif;line-height:1.4;padding:10px;pointer-events:none!important;color:#203d5d;box-shadow:0 4px 20px 4px rgba(0,20,60,.1),0 4px 80px -8px rgba(0,20,60,.2);background-color:#fff;border-radius:4px}.d3-tip:after{box-sizing:border-box;display:inline;font-size:10px;width:100%;line-height:1;color:#fff;position:absolute;pointer-events:none}.d3-tip.n:after{content:"▼";margin:-1px 0 0 0;top:100%;left:0;text-align:center}.d3-tip.e:after{content:"◀";margin:-4px 0 0 0;top:50%;left:-8px}.d3-tip.s:after{content:"▲";margin:0 0 1px 0;top:-8px;left:0;text-align:center}.d3-tip.w:after{content:"▶";margin:-4px 0 0 -1px;top:50%;left:100%}</style>`)
const state = {
id: this.createId(),
resizeEventListenerId: this.createId(),
svgWidth: 400,
svgHeight: 400,
marginTop: 35,
marginBottom: 35,
marginRight: 10,
marginLeft: 55,
container: 'body',
defaultTextFill: '#2C3E50',
defaultFont: 'Helvetica, Sans-Serif',
ctx: document.createElement('canvas').getContext('2d'),
data: null,
groups: null,
dimensions: null,
scaleX: null,
scaleY: null,
xAxis: null,
yAxis: null,
firstXTick: null,
secondXTick: null,
lastXTick: null,
firstYTick: null,
lastYTick: null,
xLeftOffset: 0.01,
xRightOffset: 0.01,
yTopOffset: 0.1,
yBottomOffset: 0.9,
xDateMax: null,
xDateMin: null,
lineShadows: true,
dashedLineDasharray: '6 6',
disableResizeTransition: true,
centerTicks: false,
xTicksCount: 30,
xTickFormat: null,
transition: true,
title: "",
labelX: null,
labelY: null,
zeroBasis: true,
dropShadowId: 'drop-shadow',
tooltip: (EVENT, { key, values, colors }, state) => {
return `<table cellspacing="0" cellpadding="0" style="color:#7B8399;margin:0px;border:none;outline:none;border-collapse:collapse;border-bottom:none">
<tr><td style="font-weight:bold;font-size:20px" rowspan="${values.length}"><div style="text-align:center;margin-right:14px;width:40px;line-height:1.1">${key.toLocaleString(undefined, {
day: "numeric",
month: "short"
})}</div></td>
<td><div style="position:relative;top:-3px;margin-right:8px;display:inline-block;width:50px;height:3px;background-color:${colors[0]};margin-top:-10px;border-radius:5px;"></div>${Math.round(values[0] * 10) / 10}</td>
</tr>
${values.filter((d, i) => i > 0).map((value, i) => {
return ` <tr><td><div style="position:relative;top:-3px;margin-right:8px;display:inline-block;width:50px;height:3px;background-color:${colors[i + 1]};margin-top:-10px;border-radius:5px;"></div>${Math.round(value * 10) / 10}</td></tr>`
}).join('')}
</table>`
},
colors: ['#F3B52F', '#F4713D', '#663F59', '#6A6E93', '#4C88B2', '#01A6C4', '#04D8D7', '#73F3E4'].concat(schemeCategory10).concat(['#D34A7C']),
// This function takes care of different kind of data formatting setting
setData: state => {
// If we don't have crossfilter group, set normal data
if (!state.groups) {
// If passed data is not array, save it as array
if (!Array.isArray(state.data)) {
return [state.data]
};
// If passed data is array, just return it
return state.data;
};
// If we have crossfilter groups in place, derive normal data from it
const groups = state.groups;
let result = [];
// Attach points to crossfilter groups (usually line chart type) {points:[{key,value}]}
result = result.concat(groups
.filter(d => d.crossfilterGroup)
.map(d => Object.assign(d, {
points: d.crossfilterGroup.all()
}))
)
// Assemble area chart data in following form {points:[{key,max,min}]}
result = result.concat(groups
.filter(d => d.crossfilterGroupMin &&
d.crossfilterGroupMax)
.map(d => {
const maxes = d.crossfilterGroupMax.all();
const mins = d.crossfilterGroupMin.all();
return Object.assign(d, {
points: maxes.map((d, i) => ({
key: d.key,
max: d.value,
min: mins[i].value
}))
})
})
)
// If directly crossfilter group was passed, treat it as line chart
result = result.concat(groups.filter(group => {
return !(group.crossfilterGroup ||
group.crossfilterGroupMin ||
group.crossfilterGroupMax)
})
.map(g => g.all())
.map(points => ({
points: points,
type: 'line',
}))
)
return result;
}
};
// Save state
this.state = state;
// Define handful d3 enter, exit, update pattern method
this.initializeEnterExitUpdatePattern();
}
// Expose container setting
container(container) {
this.setState({
container
});
return this;
}
// Expose x ticks max date settings
xDateMax(xDateMax) {
this.setState({
xDateMax
});
return this;
}
// Expose x ticks min date settings
xDateMin(xDateMin) {
this.setState({
xDateMin
});
return this;
}
// Expose data setting
data(data) {
this.setState({
data
});
return this;
}
// Expose x tick centering boolean setting
centerTicks(centerTicks) {
this.setState({
centerTicks
});
return this;
}
// Expose x tick format
xTickFormat(xTickFormat) {
this.setState({
xTickFormat
});
return this;
}
// Expose x ticks count
xTicksCount(xTicksCount) {
this.setState({
xTicksCount
});
return this;
}
// Make it possible to have uniq resize listener per chart
resizeEventListenerId(resizeEventListenerId) {
this.setState({
resizeEventListenerId
});
return this;
}
// Expose title setting
title(title) {
this.setState({
title
});
return this;
}
// Expose zeroBasis setting
zeroBasis(zeroBasis) {
this.setState({
zeroBasis
});
return this;
}
// Expose yBottomOffset proportion setting
yBottomOffset(yBottomOffset) {
this.setState({
yBottomOffset
});
return this;
}
// Expose label x setting
labelX(labelX) {
this.setState({
labelX
});
return this;
}
// Expose label y setting
labelY(labelY) {
this.setState({
labelY
});
return this;
}
// Expose tooltip override function
tooltip(tooltip) {
this.setState({
tooltip
});
return this;
}
// Expose SVG height setting
svgHeight(svgHeight) {
this.setState({
svgHeight
});
return this;
}
// Expose SVG width setting
svgWidth(svgWidth) {
this.setState({
svgWidth
});
return this;
}
// Get SVG reference
getSvgRef() {
const { svg } = this.getState();
return svg.node();
}
// Expose dimension setting
dimension(dimensions) {
if (Array.isArray(dimensions)) {
this.setState({
dimensions
});
} else if (dimensions) {
this.setState({
dimensions: [dimensions]
});
}
return this;
}
// Expose crossfilter group setting
group(groups) {
if (Array.isArray(groups)) {
this.setState({
groups
});
} else if (groups) {
this.setState({
groups: [groups]
});
} else {
this.setState({ groups: null });
}
return this;
}
// Define enter exit update pattern shorthand
initializeEnterExitUpdatePattern() {
selection.prototype.pattr = function (attribute, value, defaultProperty) {
const container = this;
container.attr(attribute, function (d, i, arr) {
if (defaultProperty && d[defaultProperty] !== undefined) return d[defaultProperty];
if (d[attribute] !== undefined) return d[attribute];
if (typeof value === 'function') return value(d, i, arr);
return value;
})
return this;
}
selection.prototype.patternify = function (params) {
const container = this;
const selector = params.selector;
const elementTag = params.tag;
const data = params.data || [selector];
// Pattern in action
let selection = container.selectAll('.' + selector).data(data, (d, i) => {
if (typeof d === 'object' && d.id) return d.id;
return i;
});
selection.exit().remove();
selection = selection.enter().append(elementTag).merge(selection);
selection.attr('class', selector);
return selection;
};
}
// Render Chart
render() {
// Define how data will be set
this.setDataProp();
// Define containers and set SVG width based on container size
this.setDynamicContainer();
// Calculate some properties
this.calculateProperties();
// Create chart scales
this.createScales();
// Draw SVG and its wrappers
this.drawSvgAndWrappers();
// Create drop shadows
this.createShadowsAndGradients();
// Invoke reusable chart redraw method
this.redraw();
// Attach interactions (tooltip, hover line)
this.attachInteractionElements();
// Attach zooming behavior to chart
this.attachZooming();
// listen for resize event and reRender accordingly
this.reRenderOnResize();
// Allow chaining
return this;
}
// Reusable redraw method
redraw() {
// Create chart axises
this.createAxises();
// Draw horizontal and vertical axises
this.drawAxises();
// Draw area shapes
this.drawAreas();
// Draw line shapes
this.drawLines();
}
// Create chart scales
createScales() {
const {
data,
calc,
xLeftOffset,
xRightOffset,
yBottomOffset,
yTopOffset,
xDateMax,
xDateMin,
zeroBasis
} = this.getState();
const {
chartWidth,
chartHeight,
dateMin,
dateMax
} = calc;
// Retrieve min, max dates and differences
const diffX = dateMax - dateMin;
// Calculate x domain min and max values (from where x axis ranges)
let domainXMin = new Date(dateMin - diffX * xLeftOffset);
let domainXMax = new Date(+dateMax + diffX * xRightOffset);
if (xDateMax) domainXMax = xDateMax;
if (xDateMin) domainXMin = xDateMin;
// Create x scale
const scaleX = scaleTime()
.domain([domainXMin, domainXMax])
.range([0, chartWidth]);
const zoomedX = scaleX;
// Retrieve min, max values and differences
const valueMax = max(data, gr => max(gr.points, d => {
if (d.value !== undefined) return d.value;
if (d.max !== undefined) return d.max;
if (d.min !== undefined) return d.min;
}));
const valueMin = min(data, gr => min(gr.points, d => {
if (d.value !== undefined) return d.value;
if (d.min !== undefined) return d.min;
if (d.max !== undefined) return d.max;
}));
const diffY = valueMax;
// Calculate domain min and max values
const domainYMin = zeroBasis ? 0 : valueMin - diffY * yBottomOffset;
const domainYMax = valueMax + diffY * yTopOffset;
// Create Y sca;e
const scaleY = scaleLinear()
.domain([domainYMax, domainYMin])
.range([0, chartHeight]);
const zoomedY = scaleY;
// Save scales into state
this.setState({
scaleX,
scaleY,
zoomedY,
zoomedX
});
}
// Create chart axises
createAxises() {
const {
// scaleX,
// scaleY,
xTicksCount,
xTickFormat,
zoomedX,
zoomedY
} = this.getState();
// Retrieve all x ticks
const xTicks = zoomedX.ticks(xTicksCount);
// Get first and last x ticks
const firstXTick = xTicks[0];
const secondXTick = xTicks[1];
const lastXTick = xTicks[xTicks.length - 1];
// Retrieve all y ticks
const yTicks = zoomedY.ticks();
// Get first and last y tick
const firstYTick = yTicks[0];
const lastYTick = yTicks[yTicks.length - 1];
// Create x axis
const xAxis = axisBottom(zoomedX)
.ticks(xTicksCount)
.tickFormat(xTickFormat)
.tickSize(-(zoomedY(lastYTick) - zoomedY(firstYTick)))
// Create y axis
const yAxis = axisLeft(zoomedY)
.tickSize(-(zoomedX(lastXTick) - zoomedX(firstXTick)));
// Save axises and tick values into state
this.setState({
xAxis, yAxis, firstXTick, secondXTick, lastXTick, firstYTick, lastYTick
});
}
// Draw axises
drawAxises() {
const {
xAxis,
yAxis,
chart,
firstXTick,
secondXTick,
lastYTick,
calc,
labelX,
labelY,
transition,
svg,
marginLeft,
title,
centerTicks,
zoomedX,
zoomedY,
data
} = this.getState();
const {
chartHeight,
chartWidth
} = calc;
// Draw title
svg.patternify({ tag: 'text', selector: 'title-label' })
.text(title)
.attr('transform', `translate(${20 + marginLeft},${20})`)
.attr('fill', '#66708a')
.attr('font-size', 16)
.attr('font-weight', 'bold')
// Draw x Label
chart.patternify({ tag: 'text', selector: 'label-x' })
.text(labelX)
.attr('transform', `translate(${chartWidth / 2 - this.getTextWidth(labelX, { fontSize: 16 }) / 2},${chartHeight + 50})`)
.attr('fill', '#66708a')
.attr('font-size', 16)
// Draw y label
chart.patternify({ tag: 'text', selector: 'label-y' })
.attr('transform', `translate(${-30},${chartHeight / 2 + this.getTextWidth(labelY, { fontSize: 16 }) / 2}) rotate(-90)`)
.text(labelY)
.attr('fill', '#66708a')
.attr('font-size', 16)
// Draw axis wrapper group
const axisWrapper = chart
.patternify({
tag: 'g',
selector: 'axis-wrapper'
})
// Draw x axis wrapper group
const xAxisWrapper = axisWrapper
.patternify({
tag: 'g',
selector: 'x-axis-wrapper'
})
.attr('transform', `translate(0,${chartHeight})`)
// Draw y axis wrapper
const yAxisWrapper = axisWrapper
.patternify({
tag: 'g',
selector: 'y-axis-wrapper'
})
if (data.every(d => d.points.length === 0)) return;
if (transition) {
// Draw and transition x axis
xAxisWrapper
.transition()
.call(xAxis)
// Transition and draw y axis
yAxisWrapper
.transition()
.call(yAxis);
} else {
xAxisWrapper.call(xAxis);
yAxisWrapper.call(yAxis);
}
// Remove domain lines
axisWrapper.selectAll('.domain').remove();
// Make all tick lines dashed and change color as well
axisWrapper.selectAll('.tick line')
.attr('stroke-dasharray', '5 5')
.attr('stroke', '#DADBDD')
// Change color of all axis texts
axisWrapper.selectAll('text').attr('fill', '#66708a');
// Change position of all texts
xAxisWrapper.selectAll('text')
.transition()
.attr('y', d => {
//centerTicks
return (zoomedY(lastYTick) || chartHeight) - chartHeight + 15
})
.attr('x', d => {
if (centerTicks) {
return (zoomedX(secondXTick) - zoomedX(firstXTick)) / 2;
} else {
return 0
}
})
// Move grid lines to first tick value
xAxisWrapper.selectAll('line')
.attr('transform', `translate(0,${(zoomedY(lastYTick) || chartHeight) - chartHeight})`);
// Change position of all texts
yAxisWrapper.selectAll('text')
.transition()
.attr('x', (zoomedX(firstXTick) || 0) - 10);
// Move grid lines to first tick value
yAxisWrapper.selectAll('line').attr('transform', `translate(${(zoomedX(firstXTick) || 0)})`);
}
// Draw line shapes
drawLines() {
const { data, chart, transition, lineShadows, zoomedX, zoomedY, dropShadowId, colors, dashedLineDasharray, firstXTick, lastXTick } = this.getState();
// Filter lines
const filteredLinesData = data.filter(d => d.type === 'line')
.map(l => Object.assign({}, l, {
points: l.points.filter(p => p.key >= firstXTick && p.key <= lastXTick)
}))
// Create line path func
const linePathDefined = line()
.curve(curveMonotoneX)
.x(d => zoomedX(d.key))
.y(d => zoomedY(d.value))
.defined(d => !d.dashed)
// Create line path func
const linePathUndefined = line()
.curve(curveMonotoneX)
.x(d => zoomedX(d.key))
.y(d => zoomedY(d.value))
.defined(d => d.dashed)
// Get colors
const getColor = (d, i) => {
return colors[colors.length - (i % colors.length) - 1]
}
// Set dashed colors
filteredLinesData.forEach((d, i) => {
d.dashedColor = getColor(d, i);
})
// Get dashed lines
const dashed = filteredLinesData.filter(d => d.points.some(p => p.dashed))
.map(v => Object.assign({ dashed: true }, v))
// Create line wrapper group
const linesWrapper = chart.patternify({ tag: 'g', selector: 'lines-wrapper' });
// Create line paths
const paths = linesWrapper
.patternify({
tag: 'path',
selector: "lines",
data: filteredLinesData.concat(dashed)
})
.pattr('stroke', (d, i) => d.dashedColor || getColor(d, i), 'color')
.pattr('stroke-width', 4)
.attr('stroke-linejoin', 'round')
.attr('stroke-linecap', 'round')
.attr('fill', 'none')
.pattr('stroke-dasharray', d => {
if (d.dashed) return dashedLineDasharray;
return 'auto';
})
if (lineShadows) {
paths.style('filter', `url(#${dropShadowId})`);
}
if (transition) {
// Transition line paths its actual shape
paths.transition()
.attrTween('d', function (d) {
var previous = select(this).attr('d');
var current = d.dashed ? linePathUndefined(d.points) : linePathDefined(d.points);
return interpolatePath(previous, current);
});
} else {
paths.attr('d', d => d.dashed ? linePathUndefined(d.points) : linePathDefined(d.points));
}
}
// Draw area shapes
drawAreas() {
const { chart, zoomedX, zoomedY, data, colors, transition, firstXTick, lastXTick } = this.getState();
// Filter areas
const filteredAreasData = data.filter(d => d.type === 'area')
.map(l => Object.assign({}, l, {
points: l.points.filter(p => p.key >= firstXTick && p.key <= lastXTick)
}))
// Create areas wrapper group
const areasWrapper = chart.patternify({
tag: 'g',
selector: 'lines-wrapper'
});
// Retrieve neccessary amounts of colors and reverse them
const colorsUsed = colors.filter((d, i) => i < filteredAreasData.length).reverse();
// Create ara path calculation function
const areaPath = area()
.curve(curveMonotoneX)
.x(d => zoomedX(d.key))
.y0(d => zoomedY(d.min))
.y1(d => zoomedY(d.max))
// Create area paths
const areas = areasWrapper.patternify({
tag: 'path',
selector: "areas",
data: filteredAreasData
})
.pattr('stroke', 'none')
.pattr('stroke-width', 1)
.attr('stroke-linejoin', 'round')
.attr('stroke-linecap', 'round')
.pattr('opacity', 1)
.pattr('fill-opacity', 1)
.pattr('fill', (d, i) => colorsUsed[i % colorsUsed.length], 'color')
if (transition) {
// Transition area to its shape
areas.transition()
.attrTween('d', function (d) {
var previous = select(this).attr('d');
var current = areaPath(d.points);
return interpolatePath(previous, current);
});
} else {
areas.attr('d', d => areaPath(d.points));
}
}
// Add interaction to chart
attachInteractionElements() {
const { svg, chart, calc, data, colors } = this.getState();
const { chartHeight } = calc;
const that = this;
// Create hover line wrapper element
const hoverLineWrapper = chart.patternify({
tag: 'g',
selector: 'vertical-line-wrapper'
})
.attr('opacity', 0)
// Create hover rectangle shape
hoverLineWrapper.patternify({ tag: 'rect', selector: 'hover-rect' })
.attr('width', 1)
.attr('height', chartHeight)
.attr('fill', 'url(#gradient)');
// Create points' white outline
hoverLineWrapper.patternify({
tag: 'circle',
selector: 'points-outer',
data: data.filter(d => d.type === 'line')
})
.attr('cx', 0)
.attr('cy', 10)
.attr('r', 7)
.attr('fill', 'white')
// Create points inner circle
hoverLineWrapper.patternify({
tag: 'circle',
selector: 'points-inner',
data: data.filter(d => d.type === 'line')
})
.attr('cx', 0)
.attr('cy', 10)
.pattr('r', 5)
.pattr('fill', (d, i) => colors[colors.length - (i % colors.length) - 1], 'color')
// Create circle, from which tip will be fired
hoverLineWrapper.patternify({
tag: 'circle',
selector: 'circle-tip',
data: ['tip']
})
.attr('cx', 0)
.attr('cy', 40)
.attr('r', 0)
.pattr('fill', (d, i) => colors[colors.length - (i % colors.length) - 1], 'color')
// Listen and handle svg events
svg
.on('mousemove', function (event, d) {
const { marginLeft, data, zoomedX, prevIndex, zoomedY, tip, calc } = that.getState();
const { chartWidth } = calc;
// Get actual x position (taking margin into account)
const actualX = pointer(event)[0] - marginLeft;
// Get value from position
const v = zoomedX.invert(actualX)
// Retrieve lines
const lines = data.filter(d => d.type === 'line');
// If lines not found, don't display hover line
if (!lines.length) return;
// Get nearest date value abnd index from data
const { value, index } = that.nearest(lines[0]?.points.map(d => d.key), v, prevIndex || 0) || {};
// If destructured value is not defined, return
if (value === undefined) return;
// Get all related points
const points = data.filter(d => d.type === 'line').map(d => d.points[index]);
// Get all related y values
const yValues = points.map(p => p?.value).filter(d => d !== null && d !== undefined);
// If y values not found, then break action
if (!yValues.length) return;
// Position hover points to respective y coordinates
hoverLineWrapper.selectAll('.points-inner')
.attr('cy', (d, i) => zoomedY(yValues[i]));
hoverLineWrapper.selectAll('.points-outer')
.attr('cy', (d, i) => zoomedY(yValues[i]));
// Calculate tip position
const tipPositionX = zoomedX(new Date(value));
// Move hoverline to its position
hoverLineWrapper
.attr('opacity', 1)
.attr('transform', `translate(${tipPositionX})`);
// Retrieve corresponding colors
const lineColors = data.filter(d => d.type === 'line')
.map((d, i) => (d.color || d.fill || d.stroke) || colors[colors.length - (i % colors.length) - 1]);
// Display eastside or westside tooptip, depending on current position
tip
.direction(tipPositionX < chartWidth / 2 ? 'e' : 'w')
.offset([0, tipPositionX < chartWidth / 2 ? 15 : -15])
.show(event, {
key: new Date(value),
values: yValues,
colors: lineColors,
lines: lines,
points: points
}, hoverLineWrapper.select('.circle-tip').node());
// Save date index
that.setState({ prevIndex: index })
})
.on('mouseleave', function (d) {
const { tip } = that.getState();
// Hide tip
tip.hide();
// Hide hover line
hoverLineWrapper.attr('opacity', 0)
})
// Save previous index into state
this.setState({ prevIndex: 0, hoverLineWrapper })
}
// Make it possible to zoom using mouse wheel
attachZooming() {
// Get svg from state
const { svgWidth, svgHeight, savedZoom, innerWrapper, scaleX, calc } = this.getState();
const { dateMin } = calc;
// Define and attach zoom event and handlers
const zoomBehavior = zoom()
.scaleExtent([1, 1])
.translateExtent([
[scaleX(dateMin), 0],
[svgWidth, svgHeight],
])
.on('start', (event) => this.zoomStarted(event))
.on("zoom", (event) => this.zoomed(event))
.on('end', (event) => this.zoomEnded(event));
if (savedZoom) {
innerWrapper
.transition()
.delay(300)
.duration(0)
.call(zoomBehavior.transform, zoomIdentity);
}
// Call zoom behavior over g group element
innerWrapper.call(zoomBehavior);
// Disable annoying double click zooming
innerWrapper.on("dblclick.zoom", null);
// Save zoom into state
this.setState({ savedZoom: zoomBehavior });
}
// Handle zoom start event
zoomStarted(event) {
// Get state items
const { tip, hoverLineWrapper } = this.getState();
// Hide tip
tip.hide();
// Hide hover line
hoverLineWrapper.attr('opacity', 0)
// Disable transition
this.setState({ transition: false });
}
// Handle zoom end event
zoomEnded(event) {
// Enable transition again
this.setState({ transition: true })
}
// Handle zoom event
zoomed(event) {
const {
svg,
scaleX,
// scaleY,
} = this.getState();
// Get transform object
const transform = event.transform;
// Hide overlay line
svg.select(".overlay-line-g").style("display", "none");
// Rescale x and y scale based on zoom event
const zoomedX = transform.rescaleX(scaleX);
// Incase we will need y zooming in future
// const zoomedY = transform.rescaleY(scaleY);
// Save scales into state
this.setState({
transform,
zoomedX,
//zoomedY, Don't save zoomed y into state
});
// Redraw based on change state
this.redraw()
}
// Calculate what size will text take when drew
getTextWidth(text, {
fontSize = 14,
fontWeight = 400
} = {}) {
const { defaultFont, ctx } = this.getState();
ctx.font = `${fontWeight || ''} ${fontSize}px ${defaultFont} `
const measurement = ctx.measureText(text);
return measurement.width;
}
// Find nearest index and value
nearest(arr, target, prevIndex) {
// Check if search is valid
if (!(arr) || arr.length === 0)
return null;
if (arr.length === 1)
return { value: arr[0], index: 0 };
// first check if it's much different from old index for faster access
if (arr.length > 80) {
const start = Math.max(prevIndex - 40, 1);
const end = Math.min(prevIndex + 40, arr.length);
for (let i = start; i < end; i++) {
if (arr[i] > target) {
let p = arr[i - 1];
let c = arr[i]
const result = Math.abs(p - target) < Math.abs(c - target) ? { value: p, index: i - 1 } : { value: c, index: i };
return result;
}
}
}
// Loop over all array items and find nearest value
for (let i = 1; i < arr.length; i++) {
// As soon as a number bigger than target is found, return the previous or current
// number depending on which has smaller difference to the target.
if (arr[i] > target) {
let p = arr[i - 1];
let c = arr[i]
const result = Math.abs(p - target) < Math.abs(c - target) ? { value: p, index: i - 1 } : { value: c, index: i };
return result;
}
}
// No number in array is bigger so return the last.
return { value: arr[arr.length - 1], index: arr.length - 1 };
}
// Create shadows for lines and gradient for hover lines
createShadowsAndGradients() {
const { svg, dropShadowId } = this.getState();
// Initialize shadow properties
const color = 'black';
const opacity = 0.2;
const filterX = -70;
const filterY = -70;
const filterWidth = 400;
const filterHeight = 400;
const feOffsetDx = 10;
const feOffsetDy = 10;
const feOffsetX = -20;
const feOffsetY = -20;
const feGaussianBlurStdDeviation = 3.1;
// Add Gradients
var defs = svg.patternify({
tag: 'defs',
selector: 'defs-element'
});
// Add linear gradient
const gradients = defs
.patternify({
tag: 'linearGradient',
selector: 'gradients',
data: ['#B450EE']
})
.attr('id', 'gradient')
.attr('x1', '0%')
.attr('y1', '0%')
.attr('x2', '0%')
.attr('y2', '100%')
// Add color stops for each hover line gradient color
gradients
.patternify({
tag: 'stop',
selector: 'gradient-stop-top',
data: (d) => [d]
})
.attr('stop-color', 'white')
.attr('offset', '0%');
gradients
.patternify({
tag: 'stop',
selector: 'gradient-stop-middle',
data: (d) => [d]
})
.attr('stop-color', '#475A8B')
.attr('offset', '50%');
gradients
.patternify({
tag: 'stop',
selector: 'gradient-stop-bottom',
data: (d) => [d]
})
.attr('stop-color', 'white')
.attr('offset', '100%');
// Add Shadows
var filter = defs
.patternify({
tag: 'filter',
selector: 'shadow-filter-element'
})
.attr('id', dropShadowId)
.attr('y', `${filterY}%`)
.attr('x', `${filterX}%`)
.attr('height', `${filterHeight}%`)
.attr('width', `${filterWidth}%`);
filter
.patternify({
tag: 'feGaussianBlur',
selector: 'feGaussianBlur-element'
})
.attr('in', 'SourceAlpha')
.attr('stdDeviation', feGaussianBlurStdDeviation)
.attr('result', 'blur');
filter
.patternify({
tag: 'feOffset',
selector: 'feOffset-element'
})
.attr('in', 'blur')
.attr('result', 'offsetBlur')
.attr('dx', feOffsetDx)
.attr('dy', feOffsetDy)
.attr('x', feOffsetX)
.attr('y', feOffsetY);
filter
.patternify({
tag: 'feFlood',
selector: 'feFlood-element'
})
.attr('in', 'offsetBlur')
.attr('flood-color', color)
.attr('flood-opacity', opacity)
.attr('result', 'offsetColor');
filter
.patternify({
tag: 'feComposite',
selector: 'feComposite-element'
})
.attr('in', 'offsetColor')
.attr('in2', 'offsetBlur')
.attr('operator', 'in')
.attr('result', 'offsetBlur');
var feMerge = filter.patternify({
tag: 'feMerge',
selector: 'feMerge-element'
});
feMerge.patternify({
tag: 'feMergeNode',
selector: 'feMergeNode-blur'
}).attr('in', 'offsetBlur');
feMerge.patternify({
tag: 'feMergeNode',
selector: 'feMergeNode-graphic'
}).attr('in', 'SourceGraphic');
}
// Listen resize event and resize on change
reRenderOnResize() {
const {
resizeEventListenerId,
d3Container,
svgWidth
} = this.getState();
select(window).on('resize.' + resizeEventListenerId, () => {
const { timeoutId, transationTimeoutId } = this.getState();
if (timeoutId) clearTimeout(timeoutId);
if (transationTimeoutId) clearTimeout(transationTimeoutId);
const newTimeoutId = setTimeout(d => {
const { disableResizeTransition } = this.getState();
const containerRect = d3Container.node().getBoundingClientRect();
const newSvgWidth = containerRect.width > 0 ? containerRect.width : svgWidth;
this.setState({
svgWidth: newSvgWidth
});
if (disableResizeTransition) {
this.setState({ transition: false })
}
this.render();
this.setState({});
const newTransationTimeoutId = setTimeout(v => {
this.setState({ transition: true })
}, 500)
this.setState({ transationTimeoutId: newTransationTimeoutId })
}, 1000)
this.setState({ timeoutId: newTimeoutId })
});
}
// Draw SVG and g wrapper
drawSvgAndWrappers() {
const {
d3Container,
svgWidth,
svgHeight,
defaultFont,
calc,
tooltip
} = this.getState();
const {
chartLeftMargin,
chartTopMargin
} = calc;
// Create tip instance
const tip = d3Tip()
.direction('e')
.offset([0, 15])
.attr('class', 'd3-tip')
.html((EVENT, d) => tooltip(EVENT, d, this.getState));
// Draw SVG
const svg = d3Container
.patternify({
tag: 'svg',
selector: 'svg-chart-container'
})
.attr('width', svgWidth)
.attr('height', svgHeight)
.attr('font-family', defaultFont)
.style('background-color', '#FFFFFF')
// .style('overflow', 'visible')
// Call tip on SVG
svg.call(tip)
// Add wraper group element
const innerWrapper = svg
.patternify({
tag: 'g',
selector: 'inner-wrapper'
})
.attr('transform', 'translate(' + chartLeftMargin + ',' + chartTopMargin + ')');
innerWrapper.selectAll('.drag-handler-rect').remove();
// Add background rect , which will receive and handle zoom events
innerWrapper
.patternify({ tag: "rect", selector: "drag-handler-rect" })
.attr("width", svgWidth)
.attr("height", svgHeight)
.attr("fill", "none")
.attr("pointer-events", "all");
// Add container g element
const chart = innerWrapper
.patternify({
tag: 'g',
selector: 'chart'
})
this.setState({
chart,
innerWrapper,
svg,
tip
});
}
// Calculate some properties
calculateProperties() {
const { data } = this.getState();
const {
marginTop,
marginLeft,
marginRight,
marginBottom,
svgWidth,
svgHeight
} = this.getState();
// Calculated properties
const calc = {
id: this.createId(), // id for event handlings,
chartTopMargin: marginTop,
chartLeftMargin: marginLeft,
chartWidth: svgWidth - marginRight - marginLeft,
chartHeight: svgHeight - marginBottom - marginTop,
dateMin: min(data, gr => min(gr.points, d => d.key)),
dateMax: max(data, gr => max(gr.points, d => d.key)),
};
this.setState({
calc
})
}
// Set dynamic width for chart
setDynamicContainer() {
const {
container,
svgWidth
} = this.getState();
// Drawing containers
const d3Container = select(container);
const containerRect = d3Container.node().getBoundingClientRect();
let newSvgWidth = containerRect.width > 0 ? containerRect.width : svgWidth;
this.setState({
d3Container,
svgWidth: newSvgWidth
});
}
// Get current date from state
getData() {
const state = this.getState();
const {
setData
} = state;
return setData(state);
}
// Set data property
setDataProp() {
const data = this.getData();
// Support additional properties for convenience
data.forEach(d => {
d.points.forEach(p => {
if (p.x !== undefined) p.key = p.x;
if (p.y !== undefined) {
if (d.type === 'area') p.max = p.y;
if (d.type === 'line') p.value = p.y;
}
if (p.y0 !== undefined) p.min = p.y0;
})
})
this.setState({
data
})
}
}