billboard.js
Version:
Re-usable easy interface JavaScript chart library, based on D3 v4+
376 lines (373 loc) • 13.2 kB
JavaScript
/*!
* 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.0.1
*/
import { select } from 'd3-selection';
import { line, curveLinear } from 'd3-shape';
import { $FUNNEL, $COMMON } from '../../config/classes.js';
import { isObject } from '../../module/util/type-checks.js';
/**
* 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;
// Use Math.max to ensure size is not less than neck
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 $$ = this;
const { config } = $$;
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 = [];
// Use relative coordinates (0, 0) as origin within the funnel group
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: [M(start), 1(end-start), 2(end-end), 3(start-end), 4(close)]
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 });
// Use relative coordinates (0, 0) as origin within the funnel group
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 < 0.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 = [];
// 20 segments provide smooth cubic easing curve without unnecessary overhead
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);
// Use relative coordinates (0, 0) as origin within the funnel group
// Common calculations
const neckHalf = (isRotated ? height - neck.width : width - neck.width) / 2;
const bodySize = isRotated ? width - neck.height : height - neck.height;
// Generate edge points based on orientation
const edge1 = _generateSmoothEdgePoints(0, bodySize, isRotated ? 0 : width, isRotated ? neckHalf : width - neckHalf, isRotated);
const edge2 = [];
// Add neck points if neck exists
if (neck.height > 0) {
edge1.push(isRotated ? [width, neckHalf] : [width - neckHalf, height]);
edge2.push(isRotated ? [width, height - neckHalf] : [neckHalf, height]);
}
// Generate opposite edge
edge2.push(..._generateSmoothEdgePoints(bodySize, 0, isRotated ? height - neckHalf : neckHalf, isRotated ? height : 0, isRotated));
// Insert corner points to maintain flat top/left edges
// isRotated: swap indices for horizontal vs vertical corner points
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 shapeFunnel = {
/**
* 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 } : undefined)
.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 $$ = this;
const { $el: { funnel } } = $$;
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 {(d: IDataRow) => [number, 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;
// For rotated: x is main axis, y is center
// For non-rotated: y is main axis, x is center
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($$);
// Apply transform to position the funnel group
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);
}
};
export { shapeFunnel as default };