UNPKG

@aquassay/d3-horizon

Version:
273 lines (229 loc) 11.8 kB
(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 }); }));