billboard.js
Version:
Re-usable easy interface JavaScript chart library, based on D3 v4+
617 lines (616 loc) • 20.8 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.1.1
*/
import { document as doc, window as win } from "../module/browser.js";
import { isBoolean, isFunction, isObject, isString, notEmpty } from "../module/util/type-checks.js";
import { callFn, capitalize, extend, getOption, getRandom, sortValue } from "../module/util/object.js";
import { convertInputType, hasStyle } from "../module/util/dom.js";
import { checkModuleImport } from "../module/error.js";
import { $CIRCLE, $COMMON, $TEXT } from "../config/classes.js";
import Options from "../config/Options/Options.js";
import Store from "../config/Store/Store.js";
import Cache from "../module/Cache.js";
import { generateResize } from "../module/generator.js";
import convert_default from "./data/convert.js";
import data_default from "./data/data.js";
import load_default from "./data/load.js";
import interaction_default from "./interactions/interaction.js";
import category_default from "./internals/category.js";
import class_default from "./internals/class.js";
import color_default from "./internals/color.js";
import domain_default from "./internals/domain.js";
import format_default from "./internals/format.js";
import legend_default from "./internals/legend.js";
import redraw_default from "./internals/redraw.js";
import scale_default from "./internals/scale.js";
import size_default from "./internals/size.js";
import style_default from "./internals/style.js";
import text_default from "./internals/text.js";
import title_default from "./internals/title.js";
import tooltip_default from "./internals/tooltip.js";
import transform_default from "./internals/transform.js";
import type_default from "./internals/type.js";
import shape_default from "./shape/shape.js";
import { select } from "d3-selection";
import { timeFormat, timeParse, utcFormat, utcParse } from "d3-time-format";
//#region src/ChartInternal/ChartInternal.ts
/**
* Copyright (c) 2017 ~ present NAVER Corp.
* billboard.js project is licensed under the MIT license
* @ignore
*/
/**
* Get the current time in ms.
* @returns {number} Timestamp
* @private
*/
function getTime() {
return win.performance?.now?.() ?? Date.now();
}
/**
* 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
*/
var ChartInternal = class {
api;
config;
cache;
$el;
state;
charts;
data = {
xs: {},
targets: []
};
axis;
scale = {
x: null,
y: null,
y2: null,
subX: null,
subY: null,
subY2: null,
zoom: null
};
org = {
xScale: null,
xDomain: null
};
color;
patterns;
levelColor;
point;
brush;
format = {
extraLineClasses: null,
xAxisTick: null,
dataTime: null,
defaultAxisTime: null,
axisTime: null
};
constructor(api) {
const $$ = this;
$$.api = api;
$$.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) {
if ("tagName" in t) t = select(t);
t = (force !== false && duration || force) && (!state.zooming || state.dragging) && !state.resizing && state.rendered && !subchart ? t.transition(name).duration(duration) : t;
}
return t;
}
beforeInit() {
const $$ = this;
$$.callPluginHook("$beforeInit");
callFn($$.config.onbeforeinit, $$.api);
}
afterInit() {
const $$ = this;
$$.callPluginHook("$afterInit");
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;
state.datetimeId = `bb-${+/* @__PURE__ */ new Date() * getRandom()}`;
if (boost_useCssRule) {
const styleEl = doc.createElement("style");
styleEl.type = "text/css";
doc.head.appendChild(styleEl);
state.style = {
rootSelector: `.${state.datetimeId}`,
sheet: styleEl.sheet
};
$el.style = styleEl;
}
const bindConfig = {
element: bindto,
classname: "bb"
};
if (isObject(bindto)) {
bindConfig.element = bindto.element || "#chart";
bindConfig.classname = bindto.classname || bindConfig.classname;
}
$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 = $$.generateColor();
$$.levelColor = $$.generateLevelColor();
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;
if (hasAxis) {
$$.axis = $$.getAxisInstance();
config.zoom_enabled && $$.initZoom();
}
$$.data.xs = {};
$$.data.targets = $$.convertDataToTargets(data);
if (config.data_filter) $$.data.targets = $$.data.targets.filter(config.data_filter.bind($$.api));
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);
$$.updateSizes();
$$.updateScales(true);
if (hasAxis) {
const { x, y, y2, subX, subY, subY2 } = scale;
if (x) {
x.domain(sortValue($$.getXDomain($$.data.targets), !config.axis_x_inverted));
subX.domain(x.domain());
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'.");
$$.bindResize();
$$.initCanvas();
config.tooltip_show && $$.initTooltip();
$$.callPluginHook("$init");
callFn(config.oninit, $$.api);
$$.redraw({
withTransition: false,
withTransform: true,
withUpdateXDomain: true,
withUpdateOrgXDomain: true,
withTransitionForAxis: false,
initializing: true
});
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;
}
$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 touchOption = isTouch ? { passive: !isPrevented && (!isNaN(preventDefault) && preventDefault || null) === null } : void 0;
$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);
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]);
});
$$.generateTextBGColorFilter(labelsBGColor);
if (hasColorPatterns) $$.patterns.forEach((p) => $el.defs.append(() => p.node));
}
$$.updateSvgSize();
$$.bindResize();
const main = $el.svg.append("g").classed($COMMON.main, true).attr("transform", hasFunnel || hasTreemap ? null : $$.getTranslate("main"));
$el.main = main;
config.subchart_show && $$.initSubchart();
config.tooltip_show && $$.initTooltip();
config.title_text && $$.initTitle();
!hasTreemap && config.legend_show && $$.initLegend();
if (config.data_empty_label_text) main.append("text").attr("class", `${$TEXT.text} ${$COMMON.empty}`).attr("text-anchor", "middle").attr("dominant-baseline", "middle");
if (hasAxis) {
config.regions.length && $$.initRegion?.();
!config.clipPath && $$.axis.init();
}
main.append("g").classed($COMMON.chart, true).attr("clip-path", hasAxis ? state.clip.path : null);
$$.callPluginHook("$init");
$$.initChartElements();
if (hasAxis) {
hasInteraction && $$.initEventRect?.();
$$.initGrid?.();
config.clipPath && $$.axis?.init();
}
$$.updateTargets($$.data.targets);
$$.updateDimension();
callFn(config.oninit, $$.api);
$$.setBackground();
$$.redraw({
withTransition: false,
withTransform: true,
withUpdateXDomain: true,
withUpdateOrgXDomain: true,
withTransitionForAxis: false,
initializing: true
});
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 } } = $$;
$$.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 { config: { background: bg }, state, $el: { svg } } = this;
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($$)));
$$.updateTargetsForText(targets);
if (hasAxis) {
for (const shape of [
"bar",
"candlestick",
"line"
]) {
const name = capitalize(shape);
if (shape === "line" && $$.hasTypeOf(name) || $$.hasType(shape)) helper(name);
}
$$.updateTargetsForSubchart?.(targets);
} 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");
const hasPointType = $$.hasType("bubble") || $$.hasType("scatter");
if (hasPointType) $$.updateTargetForCircle?.();
$$.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 { resize_auto, resize_live } = config;
const isAutoResize = /^(true|parent)$/.test(resize_auto);
const list = [];
const redrawOnResize = () => {
const prevWidth = state.current.width;
const prevHeight = state.current.height;
$$.setContainerSize();
if (!state.resizePreview && prevWidth === state.current.width && prevHeight === state.current.height) return false;
state.resizing = true;
state.dirty.size = true;
if (config.legend_show) {
$$.updateSizes();
state.isCanvasMode ? $$.updateHtmlLegend?.() : $$.updateLegend();
}
$$.api.flush(false);
return true;
};
const liveResize = () => {
if (state.resizeLiveScale === null) state.resizeLiveScale = state.resizeRedrawTime > 16;
if (state.resizeLiveScale) {
$$.previewResize();
return;
}
const start = getTime();
if (redrawOnResize()) {
state.resizeRedrawTime = getTime() - start;
state.resizeLiveScale = state.resizeRedrawTime > 16;
}
};
const resizeFunction = generateResize(config.resize_timer, isAutoResize && resize_live ? liveResize : void 0);
list.push(() => callFn(config.onresize, $$.api));
isAutoResize && list.push(redrawOnResize);
list.push(() => {
callFn(config.onresized, $$.api);
state.resizing = false;
state.resizeLiveScale = null;
});
list.forEach((v) => resizeFunction.add(v));
$$.resizeFunction = resizeFunction;
if (resize_auto === "parent" && win.ResizeObserver) ($$.resizeFunction.resizeObserver = new win.ResizeObserver($$.resizeFunction.bind($$))).observe($el.chart.node().parentNode);
else {
if (resize_auto === "parent") 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, [
convert_default,
data_default,
load_default,
category_default,
class_default,
color_default,
domain_default,
interaction_default,
format_default,
legend_default,
redraw_default,
scale_default,
shape_default,
size_default,
style_default,
text_default,
title_default,
tooltip_default,
transform_default,
type_default
]);
//#endregion
export { ChartInternal as default };