billboard.js
Version:
Re-usable easy interface JavaScript chart library, based on D3 v4+
769 lines (766 loc) • 27.9 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 { timeParse, utcParse, timeFormat, utcFormat } from 'd3-time-format';
import { $COMMON, $TEXT, $CIRCLE } from '../config/classes.js';
import Options from '../config/Options/Options.js';
import Store from '../config/Store/Store.js';
import { document as doc, window as win } from '../module/browser.js';
import Cache from '../module/Cache.js';
import { checkModuleImport } from '../module/error.js';
import { generateResize } from '../module/generator.js';
import dataConvert from './data/convert.js';
import data from './data/data.js';
import dataLoad from './data/load.js';
import interaction from './interactions/interaction.js';
import category from './internals/category.js';
import classModule from './internals/class.js';
import color from './internals/color.js';
import domain from './internals/domain.js';
import format from './internals/format.js';
import legend from './internals/legend.js';
import redraw from './internals/redraw.js';
import scale from './internals/scale.js';
import size from './internals/size.js';
import style from './internals/style.js';
import text from './internals/text.js';
import title from './internals/title.js';
import tooltip from './internals/tooltip.js';
import transform from './internals/transform.js';
import typeInternals from './internals/type.js';
import shape from './shape/shape.js';
import { callFn, getRandom, sortValue, capitalize, getOption, extend } from '../module/util/object.js';
import { isFunction, notEmpty, isObject, isBoolean, isString } from '../module/util/type-checks.js';
import { convertInputType, hasStyle } from '../module/util/dom.js';
/**
* Copyright (c) 2017 ~ present NAVER Corp.
* billboard.js project is licensed under the MIT license
* @ignore
*/
/**
* Get SVG-only chart type reason for canvas render fallback.
* @param {object} $$ ChartInternal instance
* @returns {string|null} Unsupported chart type reason
* @private
*/
function getUnsupportedCanvasRenderType($$) {
if ($$.hasArcType()) {
return "arc charts";
}
if ($$.hasType("funnel")) {
return "funnel chart";
}
return null;
}
/**
* Fall back to SVG when canvas mode is requested for unsupported chart types.
* @param {object} $$ ChartInternal instance
* @private
*/
function fallbackUnsupportedCanvasRenderMode($$) {
const { config } = $$;
const unsupportedType = config.render_mode === "canvas" ?
getUnsupportedCanvasRenderType($$) :
null;
if (!unsupportedType) {
return;
}
win.console?.warn?.(`[billboard.js] render.mode='canvas' is ignored for ${unsupportedType}; falling back to SVG.`);
config.render_mode = "svg";
}
/**
* Internal chart class.
* - Note: Instantiated internally, not exposed for public.
* @class ChartInternal
* @ignore
* @private
*/
class ChartInternal {
api; // API interface
config; // config object
cache; // cache instance
$el; // elements
state; // state variables
charts; // all Chart instances array within page (equivalent of 'bb.instances')
// data object
data = {
xs: {},
targets: []
};
// Axis
axis; // Axis
// scales
scale = {
x: null,
y: null,
y2: null,
subX: null,
subY: null,
subY2: null,
zoom: null
};
// original values
org = {
xScale: null,
xDomain: null
};
// formatter function
color;
patterns;
levelColor;
point;
brush;
// format function
format = {
extraLineClasses: null,
xAxisTick: null,
dataTime: null, // dataTimeFormat
defaultAxisTime: null, // defaultAxisTimeFormat
axisTime: null // axisTimeFormat
};
constructor(api) {
const $$ = this;
$$.api = api; // Chart class instance alias
$$.config = new Options();
$$.cache = new Cache();
const store = new Store();
$$.$el = store.getStore("element");
$$.state = store.getStore("state");
$$.$T = $$.$T.bind($$);
}
/**
* Get the selection based on transition config
* @param {SVGElement|d3Selection} selection Target selection
* @param {boolean} force Force transition
* @param {string} name Transition name
* @returns {d3Selection}
* @private
*/
$T(selection, force, name) {
const { config, state } = this;
const duration = config.transition_duration;
const subchart = config.subchart_show;
let t = selection;
if (t) {
// in case of non d3 selection, wrap with d3 selection
if ("tagName" in t) {
t = select(t);
}
// do not transit on:
// - wheel zoom (state.zooming = true)
// - when has no subchart
// - initialization
// - resizing
const transit = ((force !== false && duration) || force) &&
(!state.zooming || state.dragging) &&
!state.resizing &&
state.rendered &&
!subchart;
// @ts-ignore
t = (transit ? t.transition(name).duration(duration) : t);
}
return t;
}
beforeInit() {
const $$ = this;
$$.callPluginHook("$beforeInit");
// can do something
callFn($$.config.onbeforeinit, $$.api);
}
afterInit() {
const $$ = this;
$$.callPluginHook("$afterInit");
// can do something
callFn($$.config.onafterinit, $$.api);
}
init() {
const $$ = this;
const { config, state, $el } = $$;
const { boost_useCssRule, bindto } = config;
checkModuleImport($$);
fallbackUnsupportedCanvasRenderMode($$);
const hasArcType = $$.hasArcType();
state.hasRadar = !state.hasAxis && $$.hasType("radar");
state.hasFunnel = !state.hasAxis && $$.hasType("funnel");
state.hasTreemap = !state.hasAxis && $$.hasType("treemap");
state.hasAxis = !hasArcType && !state.hasFunnel && !state.hasTreemap;
// datetime to be used for uniqueness
state.datetimeId = `bb-${+new Date() * getRandom()}`;
if (boost_useCssRule) {
// append style element
const styleEl = doc.createElement("style");
// styleEl.id = styleId;
styleEl.type = "text/css";
doc.head.appendChild(styleEl);
state.style = {
rootSelector: `.${state.datetimeId}`,
sheet: styleEl.sheet
};
// used on .destroy()
$el.style = styleEl;
}
const bindConfig = {
element: bindto,
classname: "bb"
};
if (isObject(bindto)) {
bindConfig.element = bindto.element || "#chart";
bindConfig.classname = bindto.classname || bindConfig.classname;
}
// select bind element
$el.chart = isFunction(bindConfig.element.node) ?
bindto.element :
select(bindConfig.element || []);
if ($el.chart.empty()) {
$el.chart = select(doc.body.appendChild(doc.createElement("div")));
}
$el.chart.html("")
.classed(bindConfig.classname, true)
.classed(state.datetimeId, boost_useCssRule)
.style("position", "relative");
$$.initParams();
$$.initToRender();
}
/**
* Initialize the rendering process
* @param {boolean} forced Force to render process
* @private
*/
initToRender(forced) {
const $$ = this;
const { config, state, $el: { chart } } = $$;
const isHidden = () => hasStyle(chart, { display: "none", visibility: "hidden" });
const isLazy = config.render.lazy === false ? false : config.render.lazy || isHidden();
const MutationObserver = win.MutationObserver;
if (isLazy && MutationObserver && config.render.observe !== false && !forced) {
new MutationObserver((mutation, observer) => {
if (!isHidden()) {
observer.disconnect();
!state.rendered && $$.initToRender(true);
}
}).observe(chart.node(), {
attributes: true,
attributeFilter: ["class", "style"]
});
}
if (!isLazy || forced) {
$$.convertData(config, res => {
$$.initWithData(res);
$$.afterInit();
});
}
}
initParams() {
const $$ = this;
const { config, format, state } = $$;
if (config.render_mode === "canvas") {
$$.prepareCanvasConfig?.();
}
// color settings
$$.color = $$.generateColor();
$$.levelColor = $$.generateLevelColor();
// when 'padding=false' is set, disable axes and subchart. Because they are useless.
if (config.padding === false) {
config.axis_x_show = false;
config.axis_y_show = false;
config.axis_y2_show = false;
config.subchart_show = false;
}
if (config.render_mode !== "canvas" && ($$.hasPointType() || $$.hasLegendDefsPoint?.())) {
$$.point = $$.generatePoint();
}
if (state.hasAxis) {
$$.initClip();
format.extraLineClasses = $$.generateExtraLineClass();
format.dataTime = config.data_xLocaltime ? timeParse : utcParse;
format.axisTime = config.axis_x_localtime ? timeFormat : utcFormat;
const isDragZoom = config.zoom_enabled && config.zoom_type === "drag";
format.defaultAxisTime = d => {
const { x, zoom } = $$.scale;
const isZoomed = isDragZoom ?
zoom :
zoom && x.orgDomain().toString() !== zoom.domain().toString();
const specifier = (d.getMilliseconds() && ".%L") ||
(d.getSeconds() && ".:%S") ||
(d.getMinutes() && "%I:%M") ||
(d.getHours() && "%I %p") ||
(d.getDate() !== 1 && "%b %d") ||
(isZoomed && d.getDate() === 1 && "%b'%y") ||
(d.getMonth() && "%-m/%-d") || "%Y";
return format.axisTime(specifier)(d);
};
}
const { legend_position, legend_inset_anchor, axis_rotated } = config;
state.isLegendRight = legend_position === "right";
state.isLegendInset = legend_position === "inset";
state.isLegendTop = legend_inset_anchor === "top-left" ||
legend_inset_anchor === "top-right";
state.isLegendLeft = legend_inset_anchor === "top-left" ||
legend_inset_anchor === "bottom-left";
state.rotatedPadding.top = $$.getResettedPadding(state.rotatedPadding.top);
state.rotatedPadding.right = axis_rotated && !config.axis_x_show ? 0 : 30;
state.inputType = convertInputType(config.interaction_inputType_mouse, config.interaction_inputType_touch);
}
initWithData(data) {
const $$ = this;
const { config, scale, state, $el, org } = $$;
const { hasAxis, hasFunnel, hasTreemap } = state;
const hasInteraction = config.interaction_enabled;
const hasPolar = $$.hasType("polar");
const labelsBGColor = config.data_labels_backgroundColors;
// for arc type, set axes to not be shown
// $$.hasArcType() && ["x", "y", "y2"].forEach(id => (config[`axis_${id}_show`] = false));
if (hasAxis) {
$$.axis = $$.getAxisInstance();
config.zoom_enabled && $$.initZoom();
}
// Init data as targets
$$.data.xs = {};
$$.data.targets = $$.convertDataToTargets(data);
if (config.data_filter) {
$$.data.targets = $$.data.targets.filter(config.data_filter.bind($$.api));
}
// Set targets to hide if needed
if (config.data_hide) {
$$.addHiddenTargetIds(config.data_hide === true ? $$.mapToIds($$.data.targets) : config.data_hide);
}
if (config.legend_hide) {
$$.addHiddenLegendIds(config.legend_hide === true ? $$.mapToIds($$.data.targets) : config.legend_hide);
}
// Init sizes and scales
$$.updateSizes();
$$.updateScales(true);
// retrieve scale after the 'updateScales()' is called
if (hasAxis) {
const { x, y, y2, subX, subY, subY2 } = scale;
// Set domains for each scale
if (x) {
x.domain(sortValue($$.getXDomain($$.data.targets), !config.axis_x_inverted));
subX.domain(x.domain());
// Save original x domain for zoom update
org.xDomain = x.domain();
}
if (y) {
y.domain($$.getYDomain($$.data.targets, "y"));
subY.domain(y.domain());
}
if (y2) {
y2.domain($$.getYDomain($$.data.targets, "y2"));
subY2 && subY2.domain(y2.domain());
}
}
if (config.render_mode === "canvas") {
if (!$$.initCanvas) {
throw Error("[billboard.js] Please import and call canvas() to use render.mode='canvas'.");
}
// Bind resize event before tooltip init because tooltip position registers resize hooks.
$$.bindResize();
$$.initCanvas();
config.tooltip_show && $$.initTooltip();
$$.callPluginHook("$init");
// oninit callback
callFn(config.oninit, $$.api);
$$.redraw({
withTransition: false,
withTransform: true,
withUpdateXDomain: true,
withUpdateOrgXDomain: true,
withTransitionForAxis: false,
initializing: true
});
// data.onmin/max callback
if (config.data_onmin || config.data_onmax) {
const minMax = $$.getMinMaxData();
callFn(config.data_onmin, $$.api, minMax.min);
callFn(config.data_onmax, $$.api, minMax.max);
}
state.rendered = true;
return;
}
// -- Basic Elements --
$el.svg = $el.chart.append("svg")
.style("overflow", "hidden")
.style("display", "block");
if (hasInteraction && state.inputType) {
const isTouch = state.inputType === "touch";
const { onclick, onover, onout } = config;
const preventDefault = config.interaction_inputType_touch?.preventDefault;
const isPrevented = (isBoolean(preventDefault) && preventDefault) || false;
const preventThreshold = (!isNaN(preventDefault) && preventDefault) || null;
const touchOption = isTouch ?
{
passive: !isPrevented && preventThreshold === null
} :
undefined;
$el.svg
.on("click", onclick?.bind($$.api) || null)
.on(isTouch ? "touchstart" : "mouseenter", onover?.bind($$.api) || null, touchOption)
.on(isTouch ? "touchend" : "mouseleave", onout?.bind($$.api) || null);
}
config.svg_classname && $el.svg.attr("class", config.svg_classname);
// Define defs
const hasColorPatterns = isFunction(config.color_tiles) && $$.patterns;
if (hasAxis || hasColorPatterns || hasPolar || hasTreemap ||
labelsBGColor || $$.hasLegendDefsPoint?.()) {
$el.defs = $el.svg.append("defs");
if (hasAxis) {
["id", "idXAxis", "idYAxis", "idGrid"].forEach(v => {
$$.appendClip($el.defs, state.clip[v]);
});
}
// Append data background color filter definition
$$.generateTextBGColorFilter(labelsBGColor);
// set color patterns
if (hasColorPatterns) {
$$.patterns.forEach(p => $el.defs.append(() => p.node));
}
}
$$.updateSvgSize();
// Bind resize event
$$.bindResize();
// Define regions
const main = $el.svg.append("g")
.classed($COMMON.main, true)
.attr("transform", hasFunnel || hasTreemap ? null : $$.getTranslate("main"));
$el.main = main;
// initialize subchart when subchart show option is set
config.subchart_show && $$.initSubchart();
config.tooltip_show && $$.initTooltip();
config.title_text && $$.initTitle();
!hasTreemap && config.legend_show && $$.initLegend();
// -- Main Region --
// text when empty
if (config.data_empty_label_text) {
main.append("text")
.attr("class", `${$TEXT.text} ${$COMMON.empty}`)
.attr("text-anchor", "middle") // horizontal centering of text at x position in all browsers.
.attr("dominant-baseline", "middle"); // vertical centering of text at y position in all browsers, except IE.
}
if (hasAxis) {
// Regions (optional module — initRegion installed by regions resolver)
config.regions.length && $$.initRegion?.();
// Add Axis here, when clipPath is 'false'
!config.clipPath && $$.axis.init();
}
// Define g for chart area
main.append("g")
.classed($COMMON.chart, true)
.attr("clip-path", hasAxis ? state.clip.path : null);
$$.callPluginHook("$init");
$$.initChartElements();
if (hasAxis) {
// Cover whole with rects for events
hasInteraction && $$.initEventRect?.();
// Grids (optional module — initGrid installed by grid resolver)
$$.initGrid?.();
// Add Axis here, when clipPath is 'true'
config.clipPath && $$.axis?.init();
}
// Set targets
$$.updateTargets($$.data.targets);
// Draw with targets
$$.updateDimension();
// oninit callback
callFn(config.oninit, $$.api);
// Set background
$$.setBackground();
$$.redraw({
withTransition: false,
withTransform: true,
withUpdateXDomain: true,
withUpdateOrgXDomain: true,
withTransitionForAxis: false,
initializing: true
});
// data.onmin/max callback
if (config.data_onmin || config.data_onmax) {
const minMax = $$.getMinMaxData();
callFn(config.data_onmin, $$.api, minMax.min);
callFn(config.data_onmax, $$.api, minMax.max);
}
config.tooltip_show && $$.initShowTooltip();
state.rendered = true;
}
/**
* Initialize chart elements
* @private
*/
initChartElements() {
const $$ = this;
const { hasAxis, hasRadar, hasTreemap } = $$.state;
const types = [];
if (hasAxis) {
const shapes = ["bar", "bubble", "candlestick", "line"];
if ($$.config.bar_front) {
shapes.push(shapes.shift());
}
for (const shape of shapes) {
const name = capitalize(shape);
if ((shape === "line" && $$.hasTypeOf(name)) || $$.hasType(shape)) {
types.push(name);
}
}
}
else if (hasTreemap) {
types.push("Treemap");
}
else if ($$.hasType("funnel")) {
types.push("Funnel");
}
else {
const hasPolar = $$.hasType("polar");
const hasGauge = $$.hasType("gauge");
if (!hasRadar) {
types.push("Arc", "Pie");
}
if (hasGauge) {
types.push("Gauge");
}
else if (hasRadar) {
types.push("Radar");
}
else if (hasPolar) {
types.push("Polar");
}
}
for (const type of types) {
$$[`init${type}`]();
}
if (notEmpty($$.config.data_labels) && !$$.hasArcType(null, ["radar"])) {
$$.initText();
}
}
/**
* Set chart elements
* @private
*/
setChartElements() {
const $$ = this;
const { $el: { chart, svg, defs, main, tooltip, legend, title, canvas, eventOverlay, grid, needle, arcs: arc, circle: circles, bar: bars, candlestick, line: lines, area: areas, text: texts } } = $$;
// public
$$.api.$ = {
chart,
svg,
canvas,
eventOverlay,
defs,
main,
tooltip,
legend,
title,
grid,
arc,
circles,
bar: { bars },
candlestick,
line: { lines, areas },
needle,
text: { texts }
};
}
/**
* Set background element/image
* @private
*/
setBackground() {
const $$ = this;
const { config: { background: bg }, state, $el: { svg } } = $$;
if (notEmpty(bg)) {
const element = svg.select("g")
.insert(bg.imgUrl ? "image" : "rect", ":first-child");
if (bg.imgUrl) {
element.attr("href", bg.imgUrl);
}
else if (bg.color) {
element
.style("fill", bg.color)
.attr("clip-path", state.clip.path);
}
element
.attr("class", bg.class || null)
.attr("width", "100%")
.attr("height", "100%");
}
}
/**
* Update targeted element with given data
* @param {object} targets Data object formatted as 'target'
* @private
*/
updateTargets(targets) {
const $$ = this;
const { hasAxis, hasFunnel, hasRadar, hasTreemap } = $$.state;
const helper = type => $$[`updateTargetsFor${type}`](targets.filter($$[`is${type}Type`].bind($$)));
// Text
$$.updateTargetsForText(targets);
if (hasAxis) {
const shapes = ["bar", "candlestick", "line"];
for (const shape of shapes) {
const name = capitalize(shape);
if ((shape === "line" && $$.hasTypeOf(name)) || $$.hasType(shape)) {
helper(name);
}
}
// Sub Chart
$$.updateTargetsForSubchart?.(targets);
// Arc, Polar, Radar
}
else if ($$.hasArcType(targets)) {
let type = "Arc";
if (hasRadar) {
type = "Radar";
}
else if ($$.hasType("polar")) {
type = "Polar";
}
helper(type);
}
else if (hasFunnel) {
helper("Funnel");
}
else if (hasTreemap) {
helper("Treemap");
}
// Point types
const hasPointType = $$.hasType("bubble") || $$.hasType("scatter");
if (hasPointType) {
$$.updateTargetForCircle?.();
}
// Fade-in each chart
$$.filterTargetsToShowAtInit(hasPointType);
}
/**
* Display targeted elements at initialization
* @param {boolean} hasPointType whether has point type(bubble, scatter) or not
* @private
*/
filterTargetsToShowAtInit(hasPointType = false) {
const $$ = this;
const { $el: { svg }, $T } = $$;
let selector = `.${$COMMON.target}`;
if (hasPointType) {
selector += `, .${$CIRCLE.chartCircles} > .${$CIRCLE.circles}`;
}
$T(svg.selectAll(selector)
.filter(d => $$.isTargetToShow(d.id))).style("opacity", null);
}
getWithOption(options) {
const withOptions = {
Dimension: true,
EventRect: true,
Legend: false,
Subchart: true,
Transform: false,
Transition: true,
TrimXDomain: true,
UpdateXAxis: "UpdateXDomain",
UpdateXDomain: false,
UpdateOrgXDomain: false,
TransitionForExit: "Transition",
TransitionForAxis: "Transition",
Y: true
};
for (const [key, defVal] of Object.entries(withOptions)) {
const value = isString(defVal) ? withOptions[defVal] : defVal;
withOptions[key] = getOption(options, `with${key}`, value);
}
return withOptions;
}
initialOpacity(d) {
const $$ = this;
const { withoutFadeIn } = $$.state;
return $$.getBaseValue(d) !== null && withoutFadeIn[d.id] ? null : "0";
}
bindResize() {
const $$ = this;
const { $el, config, state } = $$;
const resizeFunction = generateResize(config.resize_timer);
const { resize_auto } = config;
const list = [];
list.push(() => callFn(config.onresize, $$.api));
if (/^(true|parent)$/.test(resize_auto)) {
list.push(() => {
// Skip resize if dimensions haven't changed
const prevWidth = state.current.width;
const prevHeight = state.current.height;
$$.setContainerSize();
if (prevWidth === state.current.width &&
prevHeight === state.current.height) {
return;
}
state.resizing = true;
// https://github.com/naver/billboard.js/issues/2650
if (config.legend_show) {
$$.updateSizes();
state.isCanvasMode ? $$.updateHtmlLegend?.() : $$.updateLegend();
}
$$.api.flush(false);
});
}
list.push(() => {
callFn(config.onresized, $$.api);
state.resizing = false;
});
// add resize functions
list.forEach(v => resizeFunction.add(v));
$$.resizeFunction = resizeFunction;
// attach resize event
if (resize_auto === "parent" && win.ResizeObserver) {
($$.resizeFunction.resizeObserver = new win.ResizeObserver($$.resizeFunction.bind($$)))
.observe($el.chart.node().parentNode);
}
else {
if (resize_auto === "parent") {
// ResizeObserver unavailable: parent-specific resize won't be detected
win.console?.warn?.("[billboard.js] resize.auto='parent' requires ResizeObserver; falling back to window resize.");
}
win.addEventListener("resize", $$.resizeFunction);
}
}
/**
* Call plugin hook
* @param {string} phase The lifecycle phase
* @param {Array} args Arguments
* @private
*/
callPluginHook(phase, ...args) {
this.config.plugins.forEach(v => {
if (phase === "$beforeInit") {
v.$$ = this;
this.api.plugins.push(v);
}
v[phase](...args);
});
}
}
extend(ChartInternal.prototype, [
// common
dataConvert,
data,
dataLoad,
category,
classModule,
color,
domain,
interaction,
format,
legend,
redraw,
scale,
shape,
size,
style,
text,
title,
tooltip,
transform,
typeInternals
]);
export { ChartInternal as default };