UNPKG

billboard.js

Version:

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

348 lines (347 loc) • 10.7 kB
/*! * Copyright (c) 2017 ~ present NAVER Corp. * billboard.js project is licensed under the MIT license * * billboard.js, JavaScript chart library * https://naver.github.io/billboard.js/ * * @version 4.1.1 */ import { isObject } from "../../module/util/type-checks.js"; import { $COMMON, $FUNNEL } from "../../config/classes.js"; import { select } from "d3-selection"; import { curveLinear, line } from "d3-shape"; //#region src/ChartInternal/shape/funnel.ts /** * Copyright (c) 2017 ~ present NAVER Corp. * billboard.js project is licensed under the MIT license */ /** * Get current size value * @param {boolean} checkNeck Determine if container width to not be less than neck width * @returns {object} size object * @private */ function _getSize(checkNeck = false) { const $$ = this; const { config, state: { current: { width, height } } } = $$; const padding = $$.getCurrentPadding(); const size = { width: width - padding.left - padding.right, height: height - (config.legend_show ? $$.getLegendHeight() + 10 : 0) - padding.top - padding.bottom, ...padding }; if (checkNeck) { const neck = _getNeckSize.call($$, size); const isRotated = config.funnel_rotated; size.width = Math.max(size.width, isRotated ? neck.height : neck.width); size.height = Math.max(size.height, isRotated ? neck.width : neck.height); } return size; } /** * Return neck size in pixels * @param {object} current Current size object * @returns {object} size object * @private */ function _getNeckSize(current) { const { config } = this; const isRotated = config.funnel_rotated; const [w, h] = [config.funnel_neck_width, config.funnel_neck_height].map((v, i) => isObject(v) ? current[isRotated !== !i ? "width" : "height"] * v.ratio : v); return { width: w, height: h }; } /** * Get coordinate points * @param {Array} d Data object * @returns {Array} Coordinate points * @private */ function _getCoord(d) { const $$ = this; const isRotated = $$.config.funnel_rotated; const { width, height } = _getSize.call($$, true); const coords = []; d.forEach((item, i) => { const { ratio = 0 } = item; const prev = i > 0 ? coords[i - 1][2][isRotated ? 0 : 1] : 0; const end = ratio + prev; coords.push(item.coords = isRotated ? [ [prev, 0], [end, 0], [end, height], [prev, height], [prev, 0] ] : [ [0, prev], [width, prev], [width, end], [0, end], [0, prev] ]); }); return coords; } /** * Get clip path * @returns {string} path * @private */ function _getClipPath() { const $$ = this; const isRotated = $$.config.funnel_rotated; const { width, height } = _getSize.call($$, true); const neck = _getNeckSize.call($$, { width, height }); let middleCoords; if (isRotated) { const neckY = (height - neck.width) / 2; const bodyW = width - neck.height; middleCoords = [ [bodyW, neckY], [width, neckY], [width, height - neckY], [bodyW, height - neckY], [0, height] ]; } else { const neckX = (width - neck.width) / 2; const bodyH = height - neck.height; middleCoords = [ [width, 0], [width - neckX, bodyH], [width - neckX, height], [neckX, height], [neckX, bodyH] ]; } return `M${[ [0, 0], ...middleCoords, [0, 0] ].join("L")}z`; } /** * Get funnel data * @param {object} d data object * @returns {Array} * @private */ function _getFunnelData(d) { const $$ = this; const { config } = $$; const data = d.map((d) => ({ id: d.id, value: d.values.reduce((a, b) => a + b.value, 0) })); config.data_order && data.sort($$.getSortCompareFn.bind($$)(true)); return _updateRatio.call($$, data); } /** * Update ratio value * @param {Array} data Data object * @returns {Array} Updated data object * @private */ function _updateRatio(data) { const $$ = this; const { width, height } = _getSize.call($$); const total = $$.getTotalDataSum(true); const dimension = $$.config.funnel_rotated ? width : height; data.forEach((d) => { d.ratio = d.value / total * dimension; }); return data; } /** * Easing function for smooth curve generation (ease-in-out cubic) * @param {number} t Progress value between 0 and 1 * @returns {number} Eased value * @private */ function _easeInOutCubic(t) { return t < .5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; } /** * Generate smooth edge points for spline funnel * @param {number} start Start position * @param {number} end End position * @param {number} startEdge Start edge position * @param {number} endEdge End edge position * @param {boolean} isRotated Whether funnel is rotated * @returns {Array} Array of [x, y] points * @private */ function _generateSmoothEdgePoints(start, end, startEdge, endEdge, isRotated) { const points = []; const SPLINE_POINTS = 20; for (let i = 0; i <= SPLINE_POINTS; i++) { const t = i / SPLINE_POINTS; const pos = start + (end - start) * t; const edge = startEdge + (endEdge - startEdge) * _easeInOutCubic(t); points.push(isRotated ? [pos, edge] : [edge, pos]); } return points; } /** * Generate spline clip path for funnel with smooth curved outer edges * @returns {string} SVG path string * @private */ function _getSplineClipPath() { const $$ = this; const isRotated = $$.config.funnel_rotated; const { width, height } = _getSize.call($$, true); const neck = _getNeckSize.call($$, { width, height }); const lineGen = line().x((d) => d[0]).y((d) => d[1]).curve(curveLinear); const neckHalf = (isRotated ? height - neck.width : width - neck.width) / 2; const bodySize = isRotated ? width - neck.height : height - neck.height; const edge1 = _generateSmoothEdgePoints(0, bodySize, isRotated ? 0 : width, isRotated ? neckHalf : width - neckHalf, isRotated); const edge2 = []; if (neck.height > 0) { edge1.push(isRotated ? [width, neckHalf] : [width - neckHalf, height]); edge2.push(isRotated ? [width, height - neckHalf] : [neckHalf, height]); } edge2.push(..._generateSmoothEdgePoints(bodySize, 0, isRotated ? height - neckHalf : neckHalf, isRotated ? height : 0, isRotated)); if (edge1.length > 1) { const [a, b] = isRotated ? [1, 0] : [0, 1]; edge1.splice(1, 0, [edge1[a][0], edge1[b][1]]); const lastIdx = edge2.length - 1; if (lastIdx > 0) edge2.splice(lastIdx, 0, [edge2[lastIdx - a][0], edge2[lastIdx - b][1]]); } const path1 = lineGen(edge1) || ""; const path2 = lineGen(edge2)?.replace(/^M/, "L") || ""; return isRotated ? `${path1}${path2}z` : `M0,0${path1.replace(/^M/, "L")}${path2}z`; } var funnel_default = { /** * Initialize funnel * @private */ initFunnel() { const $$ = this; const { $el } = $$; $el.funnel = $el.main.select(`.${$COMMON.chart}`).append("g").classed($FUNNEL.chartFunnels, true); $el.funnel.background = $el.funnel.append("path").classed($FUNNEL.funnelBackground, true); $$.bindFunnelEvent(); }, /** * Bind events * @private */ bindFunnelEvent() { const $$ = this; const { $el: { funnel }, config, state } = $$; if (!config.interaction_enabled) return; const getTarget = (event) => { const target = event.isTrusted ? event.target : state.eventReceiver.rect?.node(); if (target && /^path$/i.test(target.tagName)) { state.event = event; return select(target).datum(); } }; const isTouch = state.inputType === "touch"; funnel.on(isTouch ? "touchstart" : "mouseover mousemove", (event) => { const data = getTarget(event); if (data) { $$.showTooltip([data], event.target); /^(touchstart|mouseover)$/.test(event.type) && $$.setOverOut(true, data); } }, isTouch ? { passive: true } : void 0).on(isTouch ? "touchend" : "mouseout", (event) => { const data = getTarget(event); if (config.interaction_onout) { $$.hideTooltip(); $$.setOverOut(false, data); } }); }, /** * Update targets for funnel * @param {object} t Data object * @private */ updateTargetsForFunnel(t) { const $$ = this; const { $el: { funnel } } = $$; if (!funnel) $$.initFunnel(); const classChartFunnel = $$.getChartClass("Funnel"); const classFunnel = $$.getClass("funnel", true); const targets = _getFunnelData.call($$, t.filter($$.isFunnelType.bind($$))); const mainFunnelUpdate = $$.filterNullish(targets); const mainFunnel = funnel.selectAll(`.${$FUNNEL.chartFunnel}`).data(mainFunnelUpdate); mainFunnel.exit().remove(); const mainFunnelEnter = mainFunnel.enter().insert("g", `.${$FUNNEL.funnelBackground}`); mainFunnelEnter.append("path"); funnel.path = mainFunnelEnter.merge(mainFunnel).attr("class", classChartFunnel).select("path").attr("class", classFunnel).style("opacity", "0").style("fill", $$.color); }, /** * Update funnel path selection * @param {object} targets Updated target data * @private */ updateFunnel(targets) { const { $el: { funnel } } = this; const targetIds = targets.map(({ id }) => id); funnel.path = funnel.path.filter((d) => targetIds.includes(d.id)); }, /** * Generate funnel coordinate points data for text labels * @returns {function(IDataRow): Array.<Array.<number>>} Point getter function * @private */ generateGetFunnelPoints() { const $$ = this; const { config, $el: { funnel } } = $$; const isRotated = config.funnel_rotated; const targets = $$.filterTargetsToShow(funnel.path); const { top, left, width, height } = _getSize.call($$); const points = {}; let accumulated = 0; targets.each((d) => { const start = accumulated; const { ratio } = d; accumulated += ratio; const offset = isRotated ? left : top; const segmentStart = offset + start; const segmentEnd = offset + accumulated; const center = (segmentStart + segmentEnd) / 2; const crossCenter = isRotated ? top + height / 2 : left + width / 2; points[d.id] = isRotated ? [ [segmentStart, crossCenter], [segmentEnd, crossCenter], [center, crossCenter] ] : [ [crossCenter, segmentStart], [crossCenter, segmentEnd], [crossCenter, center] ]; }); return (d) => points[d.id]; }, /** * Called whenever redraw happens * @private */ redrawFunnel() { const $$ = this; const { config, $T, $el: { funnel } } = $$; const targets = $$.filterTargetsToShow(funnel.path); const coords = _getCoord.call($$, _updateRatio.call($$, targets.data())); const { top, left } = _getSize.call($$); const clipPath = (config.funnel_spline ? _getSplineClipPath : _getClipPath).call($$); funnel.attr("transform", `translate(${left}, ${top})`).attr("clip-path", `path('${clipPath}')`); funnel.background.attr("d", clipPath); $T(targets).attr("d", (_, i) => `M${coords[i].join("L")}z`).style("opacity", "1"); funnel.selectAll("g").style("opacity", null); } }; //#endregion export { funnel_default as default };