UNPKG

billboard.js

Version:

Re-usable easy interface JavaScript chart library, based on D3 v4+

478 lines (388 loc) 12.8 kB
/** * Copyright (c) 2017 ~ present NAVER Corp. * billboard.js project is licensed under the MIT license */ import {line as d3Line} from "d3-shape"; import {$COMMON, $LINE} from "../../config/classes"; import { getPointer, getRandom, isArray, isDefined, isUndefined, isValue, parseDate } from "../../module/util"; import type {IDataRow} from "../data/IData"; import {getScale} from "../internals/scale"; /** * Get stroke dasharray style value * @param {number} start Start position in path length * @param {number} end End position in path length * @param {Array} pattern Dash array pattern * @param {boolean} isLastX Weather is last x tick * @returns {object} Stroke dasharray style value and its length * @private */ function getStrokeDashArray(start: number, end: number, pattern: [number, number], isLastX = false): {dash: string, length: number} { const dash = start ? [start, 0] : pattern; for (let i = start ? start : pattern.reduce((a, c) => a + c); i <= end;) { pattern.forEach(v => { if (i + v <= end) { dash.push(v); } i += v; }); } // make sure to have even length dash.length % 2 !== 0 && dash.push(isLastX ? pattern[1] : 0); return { dash: dash.join(" "), length: dash.reduce((a, b) => a + b, 0) }; } /** * Get regions data * @param {Array} d Data object * @param {object} _regions regions to be set * @param {boolean} isTimeSeries whether is time series * @returns {object} Regions data * @private */ function getRegions(d, _regions, isTimeSeries) { const $$ = this; const regions: {start: number | string, end: number | string, style: string}[] = []; const dasharray = "2 2"; // default value // Check start/end of regions if (isDefined(_regions)) { const getValue = (v: Date | any, def: number | Date): Date | any => ( isUndefined(v) ? def : (isTimeSeries ? parseDate.call($$, v) : v) ); for (let i = 0, reg; (reg = _regions[i]); i++) { const start = getValue(reg.start, d[0].x); const end = getValue(reg.end, d[d.length - 1].x); const style = reg.style || {dasharray}; regions[i] = {start, end, style}; } } return regions; } export default { initLine(): void { const {$el} = this; $el.line = $el.main.select(`.${$COMMON.chart}`).append("g") .attr("class", $LINE.chartLines) .call(this.setCssRule(false, `.${$LINE.chartLines}`, ["pointer-events:none"])); }, updateTargetsForLine(t): void { const $$ = this; const {$el: {area, line, main}} = $$; const classChartLine = $$.getChartClass("Line"); const classLines = $$.getClass("lines", true); const classFocus = $$.classFocus.bind($$); if (!line) { $$.initLine(); } const targets = t.filter(d => !($$.isScatterType(d) || $$.isBubbleType(d))); const mainLineUpdate = main.select(`.${$LINE.chartLines}`) .selectAll(`.${$LINE.chartLine}`) .data($$.filterNullish(targets)) .attr("class", d => classChartLine(d) + classFocus(d)); const mainLineEnter = mainLineUpdate.enter().append("g") .attr("class", classChartLine) .style("opacity", "0") .style("pointer-events", $$.getStylePropValue("none")); // Lines for each data mainLineEnter.append("g") .attr("class", classLines); // Areas if ($$.hasTypeOf("Area")) { const mainLine = ( !area && mainLineEnter.empty() ? mainLineUpdate : mainLineEnter ).filter($$.isAreaType.bind($$)); $$.initArea(mainLine); } $$.updateTargetForCircle(targets, mainLineEnter); }, /** * Generate/Update elements * @param {boolean} withTransition Transition for exit elements * @param {boolean} isSub Subchart draw * @private */ updateLine(withTransition: boolean, isSub = false): void { const $$ = this; const {format: {extraLineClasses}, $el, $T} = $$; const $root = isSub ? $el.subchart : $el; const line = $root.main .selectAll(`.${$LINE.lines}`) .selectAll(`.${$LINE.line}`) .data($$.lineData.bind($$)); $T(line.exit(), withTransition) .style("opacity", "0") .remove(); $root.line = line.enter() .append("path") .attr("class", d => `${$$.getClass("line", true)(d)} ${extraLineClasses(d) || ""}`) .style("stroke", $$.color) .merge(line) .style("opacity", $$.initialOpacity.bind($$)) .attr("transform", null); }, /** * Redraw function * @param {Function} drawFn Retuned functino from .generateDrawCandlestick() * @param {boolean} withTransition With or without transition * @param {boolean} isSub Subchart draw * @returns {Array} * @private */ redrawLine(drawFn, withTransition?: boolean, isSub = false) { const $$ = this; const {$el, $T} = $$; const {line} = isSub ? $el.subchart : $el; return [ $T(line, withTransition, getRandom()) .attr("d", drawFn) .style("stroke", this.color) .style("opacity", null) ]; }, /** * Get the curve interpolate * @param {Array} d Data object * @returns {Function} * @private */ getCurve(d): Function { const $$ = this; const isRotatedStepType = $$.config.axis_rotated && $$.isStepType(d); // when is step & rotated, should be computed in different way // https://github.com/naver/billboard.js/issues/471 return isRotatedStepType ? context => { const step = $$.getInterpolate(d)(context); // keep the original method step.orgPoint = step.point; // to get rotated path data step.pointRotated = function(x, y) { this._point === 1 && (this._point = 2); const y1 = this._y * (1 - this._t) + y * this._t; this._context.lineTo(this._x, y1); this._context.lineTo(x, y1); this._x = x; this._y = y; }; step.point = function(x, y) { this._point === 0 ? this.orgPoint(x, y) : this.pointRotated(x, y); }; return step; } : $$.getInterpolate(d); }, generateDrawLine(lineIndices, isSub?: boolean): (d) => string { const $$ = this; const {config, scale} = $$; const lineConnectNull = config.line_connectNull; const isRotated = config.axis_rotated; const getPoints = $$.generateGetLinePoints(lineIndices, isSub); const yScale = $$.getYScaleById.bind($$); const xValue = d => (isSub ? $$.subxx : $$.xx).call($$, d); const yValue = (d, i) => ( $$.isGrouped(d.id) ? getPoints(d, i)[0][1] : yScale(d.id, isSub)($$.getBaseValue(d)) ); let line = d3Line(); line = isRotated ? line.x(yValue).y(xValue) : line.x(xValue).y(yValue); if (!lineConnectNull) { line = line.defined(d => $$.getBaseValue(d) !== null); } const x = isSub ? scale.subX : scale.x; return d => { const y = yScale(d.id, isSub); let values = lineConnectNull ? $$.filterRemoveNull(d.values) : d.values; let x0 = 0; let y0 = 0; let path; if ($$.isLineType(d)) { const regions = config.data_regions[d.id]; if (regions) { if ($$.isAreaRangeType(d)) { values = values.map(dv => ({...dv, value: $$.getRangedData(dv, "mid")})); } path = $$.lineWithRegions(values, scale.zoom || x, y, regions); } else { if ($$.isStepType(d)) { values = $$.convertValuesToStep(values); } path = line.curve($$.getCurve(d))(values); } } else { if (values[0]) { x0 = x(values[0].x); y0 = y(values[0].value); } path = isRotated ? `M ${y0} ${x0}` : `M ${x0} ${y0}`; } return path || "M 0 0"; }; }, /** * Set regions dasharray and get path * @param {Array} d Data object * @param {Function} x x scale function * @param {Function} y y scale function * @param {object} _regions regions to be set * @returns {stirng} Path string * @private */ lineWithRegions(d: IDataRow[], x, y, _regions): string { const $$ = this; const {config} = $$; const isRotated = config.axis_rotated; const isTimeSeries = $$.axis.isTimeSeries(); const dasharray = "2 2"; // default value const regions = getRegions.bind($$)(d, _regions, isTimeSeries); // when contains null data, can't apply style dashed const hasNullDataValue = $$.hasNullDataValue(d); let xp; let yp; let diff; let diffx2; // Set scales const xValue = isRotated ? dt => y(dt.value) : dt => x(dt.x); const yValue = isRotated ? dt => x(dt.x) : dt => y(dt.value); // Define svg generator function for region const generateM = points => `M${points[0][0]},${points[0][1]}L${points[1][0]},${points[1][1]}`; const sWithRegion = isTimeSeries ? (d0, d1, k, timeseriesDiff) => { const x0 = d0.x.getTime(); const xDiff = d1.x - d0.x; const xv0 = new Date(x0 + xDiff * k); const xv1 = new Date(x0 + xDiff * (k + timeseriesDiff)); const points = isRotated ? [[y(yp(k)), x(xv0)], [y(yp(k + diff)), x(xv1)]] : [[x(xv0), y(yp(k))], [x(xv1), y(yp(k + diff))]]; return generateM(points); } : (d0, d1, k, otherDiff) => { const x0 = x(d1.x, !isRotated); const y0 = y(d1.value, isRotated); const gap = k + otherDiff; const xValue = x(xp(k), !isRotated); const yValue = y(yp(k), isRotated); let xDiff = x(xp(gap), !isRotated); let yDiff = y(yp(gap), isRotated); // fix diff values not to overflow if (xDiff > x0) { xDiff = x0; } if (d0.value > d1.value && (isRotated ? yDiff < y0 : yDiff > y0)) { yDiff = y0; } const points = [ [xValue, yValue], [xDiff, yDiff] ]; isRotated && points.forEach(v => v.reverse()); return generateM(points); }; // Generate const axisType = {x: $$.axis.getAxisType("x"), y: $$.axis.getAxisType("y")}; let path = ""; // clone the line path to be used to get length value const target = $$.$el.line.filter(({id}) => id === d[0].id); const tempNode = target.clone().style("display", "none"); const getLength = (node, path) => node.attr("d", path).node().getTotalLength(); const dashArray = { dash: <string[]>[], lastLength: 0 }; let isLastX = false; for (let i = 0, data; (data = d[i]); i++) { const prevData = d[i - 1]; const hasPrevData = prevData && isValue(prevData.value); let style = $$.isWithinRegions(data.x, regions); // https://github.com/naver/billboard.js/issues/1172 if (!isValue(data.value)) { continue; } // Draw as normal if (isUndefined(regions) || !style || !hasPrevData) { path += `${i && hasPrevData ? "L" : "M"}${xValue(data)},${yValue(data)}`; } else if (hasPrevData) { style = (style?.dasharray || dasharray).split(" ").map(Number); // Draw with region // TODO: Fix for horizotal charts xp = getScale(axisType.x, prevData.x, data.x); yp = getScale(axisType.y, prevData.value, data.value); // when it contains null data, dash can't be applied with style if (hasNullDataValue) { const dx = x(data.x) - x(prevData.x); const dy = y(data.value) - y(prevData.value); const dd = Math.sqrt(Math.pow(dx, 2) + Math.pow(dy, 2)); diff = style[0] / dd; // dash diffx2 = diff * style[1]; // gap for (let j = diff; j <= 1; j += diffx2) { path += sWithRegion(prevData, data, j, diff); // to make sure correct line drawing if (j + diffx2 >= 1) { path += sWithRegion(prevData, data, 1, 0); } } } else { let points = <number[][]>[]; isLastX = data.x === d[d.length - 1].x; if (isTimeSeries) { const x0 = +prevData.x; const xv0 = new Date(x0); const xv1 = new Date(x0 + (+data.x - x0)); points = [ [x(xv0), y(yp(0))], // M [x(xv1), y(yp(1))] // L ]; } else { points = [ [x(xp(0)), y(yp(0))], // M [x(xp(1)), y(yp(1))] // L ]; } isRotated && points.forEach(v => v.reverse()); const startLength = getLength(tempNode, path); const endLength = getLength(tempNode, path += `L${points[1].join(",")}`); const strokeDashArray = getStrokeDashArray( startLength - dashArray.lastLength, endLength - dashArray.lastLength, style, isLastX ); dashArray.lastLength += strokeDashArray.length; dashArray.dash.push(strokeDashArray.dash); } } } if (dashArray.dash.length) { // if not last x tick, then should draw rest of path that is not drawn yet !isLastX && dashArray.dash.push(getLength(tempNode, path)); tempNode.remove(); target.attr("stroke-dasharray", dashArray.dash.join(" ")); } return path; }, isWithinRegions(withinX, withinRegions): boolean { for (let i = 0, reg; (reg = withinRegions[i]); i++) { if (reg.start < withinX && withinX <= reg.end) { return reg.style; } } return false; }, isWithinStep(that, y: number): boolean { return Math.abs(y - getPointer(this.state.event, that)[1]) < 30; }, shouldDrawPointsForLine(d): boolean { const linePoint = this.config.line_point; return linePoint === true || (isArray(linePoint) && linePoint.indexOf(d.id) !== -1); } };