@aquassay/d3-horizon
Version:
Horizon chart for D3
273 lines (229 loc) • 11.8 kB
JavaScript
(function (global, factory) {
typeof exports === 'object' && typeof module !== 'undefined' ? factory(exports, require('d3-array'), require('d3-axis'), require('d3-shape'), require('d3-scale-chromatic'), require('d3-selection'), require('d3-scale'), require('uuid'), require('d3-brush')) :
typeof define === 'function' && define.amd ? define(['exports', 'd3-array', 'd3-axis', 'd3-shape', 'd3-scale-chromatic', 'd3-selection', 'd3-scale', 'uuid', 'd3-brush'], factory) :
(global = typeof globalThis !== 'undefined' ? globalThis : global || self, factory(global.d3 = {}, global.d3Array, global.d3Axis, global.d3Shape, global.d3ScaleChromatic, global.d3Selection, global.d3Scale, global.uuid, global.d3Brush));
})(this, (function (exports, d3Array, d3Axis, d3Shape, d3ScaleChromatic, d3Selection, d3Scale, uuid, d3Brush) { 'use strict';
/**
* Get SVG URL to make an svg file export
*
* @param {SVGElement} svgElement
* @returns {String} The URL to make export
*/
const getURL = (svgElement) => {
const svg = svgElement.cloneNode(true);
const serializer = new XMLSerializer();
const rules = svg.querySelectorAll('.rule');
rules.forEach(rule => rule.remove());
const source = serializer.serializeToString(svg);
const url = `data:image/svg+xml;charset=utf-8,${encodeURIComponent(source)}`;
return url;
};
/**
* Generate Horizon chart
*
* @param {Object[]} series Series to show in Horizon chart
* @param {String} series[].name Serie name
* @param {Array[]} series[].data Serie Data
* @param {Date} series[].data[][0] Time of specific data element
* @param {Number} series[].data[][1] Value of specific data element
* @param {Object} options Horizon configuration options
* @param {Function} [options.x] Given d in data, returns the (temporal) x-value
* @param {Function} [options.y] Given d in data, returns the (quantitative) y-value
* @param {Function} [options.defined] For gaps in data
* @param {Function} [options.curve] Method of interpolation between points (Default : curveLinear)
* @param {Number} [options.marginTop] Top margin, in pixels
* @param {Number} [options.marginRight] Right margin, in pixels
* @param {Number} [options.marginBottom] Bottom margin, in pixels
* @param {Number} [options.marginLeft] Left margin, in pixels
* @param {Number} [options.width] Outer width, in pixels
* @param {Number} [options.size] Outer height of a single horizon, in pixels
* @param {Number} [options.bands] Number of bands
* @param {Number} [options.padding] Separation between adjacent horizons
* @param {Function} [options.xType] Type of x-scale (Default : scaleTime)
* @param {Number[]} [options.xDomain] [xmin, xmax]
* @param {Number[]} [options.xRange] [left, right] (Default : [marginLeft, width - marginRight])
* @param {Function} [options.yType] type of y-scale
* @param {Number[]} [options.yDomain] [ymin, ymax]. By default, each series uses its own domain. If you want all series to use the same one, you should set this parameter.
* @param {Number[]} [options.yRange] [bottom, top]
* @param {Array[]} [options.scheme] [positive, negative] : Schemes used for show positive and negative values. (Default : [schemeGreens, schemeBlues])
* @param {Function} [options.onHover] Callback on horizon hover to show tooltip with values for Example
* @param {Function} [options.onSelectStartRange] Callback on the beginning of the selection with the brush. Only available if `onSelectEndRange` is setted
* @param {Function} [options.onSelectEndRange] Callback after finish the selection with the brush. If setted, activate the brush
* @returns {SVGElement} D3 SVG to show
*/
const horizon = (series, {
x = ([x]) => x,
y = ([, y]) => y,
defined,
curve = d3Shape.curveLinear,
marginTop = 20,
marginRight = 0,
marginBottom = 0,
marginLeft = 0,
width = 640,
size = 25,
bands = 3,
padding = 1,
xType = d3Scale.scaleTime,
xDomain,
xRange = [marginLeft, width - marginRight],
yType = d3Scale.scaleLinear,
yDomain,
yRange = [size, size - bands * (size - padding)],
scheme = [d3ScaleChromatic.schemeGreens, d3ScaleChromatic.schemeBlues],
colors = [scheme[0][Math.max(3, bands)], scheme[1][Math.max(3, bands)]], // an array of colors
onHover, // tooltip element if needed to show data
onSelectEndRange,
onSelectStartRange,
} = {}) => {
const height = series.length * size + marginTop + marginBottom;
const svg = d3Selection.create('svg')
.attr('width', width)
.attr('height', height)
.attr('viewBox', [0, 0, width, height])
.attr('style', 'max-width: 100%; height: auto; height: intrinsic;')
.attr('font-family', 'sans-serif')
.attr('font-size', 10);
const xScale = xType(xDomain, xRange);
const xAxis = d3Axis.axisTop(xScale).ticks(width / 80).tickSizeOuter(0);
const seriesListeners = [];
series.forEach((serie, i) => {
if (!serie.data) {
return;
}
// Compute values.
const X = d3Array.map(serie.data, x);
const Y = d3Array.map(serie.data, y);
if (defined === undefined) {
defined = (d, i) => !isNaN(X[i]) && !isNaN(Y[i]);
}
const D = d3Array.map(serie.data, defined);
const I = serie.data.map((data, i) => i);
const yMax = Math.max(Math.abs(d3Array.min(Y)), d3Array.max(Y));
let yDomainLocal = yDomain;
if (yDomainLocal === undefined) {
yDomainLocal = [0, yMax];
}
const positiveY = Y.map(y => (y >= 0 ? y : 0));
const negativeY = Y.map(y => (y < 0 ? Math.abs(y) : 0));
// Construct scales and axes.
const yScale = yType(yDomainLocal, yRange);
// A unique identifier for clip paths (to avoid conflicts).
const uid = `${i}-${uuid.v4()}`;
// Construct an area generator.
const areaNegative = d3Shape.area()
.defined(i => D[i])
.curve(curve)
.x(i => xScale(X[i]))
.y0(yScale(0))
.y1(i => yScale(negativeY[i]));
const areaPositive = d3Shape.area()
.defined(i => D[i])
.curve(curve)
.x(i => xScale(X[i]))
.y0(yScale(0))
.y1(i => yScale(positiveY[i]));
const g = svg.append('g')
.attr('transform', `translate(0,${i * size + marginTop})`);
const defs = g.append('defs');
defs.append('clipPath')
.attr('id', `${uid}-clip-${i}`)
.append('rect')
.attr('y', padding)
.attr('width', width)
.attr('height', size - padding);
defs.append('path')
.attr('id', `${uid}-path-positive-${i}`)
.attr('d', areaPositive(I));
defs.append('path')
.attr('id', `${uid}-path-negative-${i}`)
.attr('d', areaNegative(I));
g
.attr('clip-path', `url(#${uid}-clip-${i})`)
.append('g')
.selectAll('use')
.data((d, i) => new Array(bands).fill(i))
.join('use')
.attr('fill', (_, i) => colors[0][i + Math.max(0, 3 - bands)])
.attr('transform', (_, i) => `translate(0,${i * size})`)
.attr('xlink:href', `#${uid}-path-positive-${i}`);
g
.attr('clip-path', `url(#${uid}-clip-${i})`)
.append('g')
.selectAll('use')
.data((d, i) => new Array(bands).fill(i))
.join('use')
.attr('fill', (_, i) => colors[1][i + Math.max(0, 3 - bands)])
.attr('transform', (_, i) => `translate(0,${i * size})`)
.attr('xlink:href', `#${uid}-path-negative-${i}`);
g.append('text')
.attr('x', marginLeft + 5)
.attr('y', (size + padding) / 2)
.attr('dy', '0.35em')
.attr('fill', 'black')
.attr('font-size', '12px')
.attr('font-weight', 'bold')
.attr('stroke-width', 3)
.attr('stroke', 'white')
.attr('paint-order', 'stroke')
.text(serie.name);
const listener = (mouseX) => {
const index = d3Array.bisector(x).left(serie.data, xScale.invert(mouseX), 0, serie.data.length - 1);
return {
name : serie.name,
value : Y[index],
};
};
seriesListeners.push(listener);
});
// Since there are normally no left or right margins, don’t show ticks that
// are close to the edge of the chart, as these ticks are likely to be clipped.
svg.append('g')
.attr('transform', `translate(0,${marginTop})`)
.call(xAxis)
.call(g => g.selectAll('.tick')
.filter(d => xScale(d) < 10 || xScale(d) > width - 10)
.remove())
.call(g => g.select('.domain').remove());
const ruler = svg.append('line')
.attr('class', 'rule')
.attr('stroke', 'black')
.attr('stroke-dasharray', '1,1')
.attr('y1', 0)
.attr('y2', height)
.attr('x1', 0.5)
.attr('x2', 0.5);
svg.on('mousemove touchmove', (event) => {
const [x] = d3Selection.pointer(event, svg.node());
ruler.attr('x1', x).attr('x2', x);
if (onHover) {
const values = seriesListeners.map(listener => listener(x));
onHover({
time : xScale.invert(x),
event,
values,
});
}
});
if (onSelectEndRange) {
const brush = d3Brush.brushX();
svg.append('g')
.attr('class', 'brush')
.call(brush.on('start', (evt) => {
if (onSelectStartRange) {
onSelectStartRange(evt);
}
}))
.call(brush.on('end', (evt) => {
const [x1,x2] = evt.selection;
const start = xScale.invert(x1);
const end = xScale.invert(x2);
onSelectEndRange([start, end]);
}));
}
return svg.node();
};
exports.getURL = getURL;
exports.horizon = horizon;
Object.defineProperty(exports, '__esModule', { value: true });
}));