UNPKG

billboard.js

Version:

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

865 lines (709 loc) 20 kB
/** * Copyright (c) 2017 ~ present NAVER Corp. * billboard.js project is licensed under the MIT license * @ignore */ import {select as d3Select} from "d3-selection"; import { timeFormat as d3TimeFormat, timeParse as d3TimeParse, utcFormat as d3UtcFormat, utcParse as d3UtcParse } from "d3-time-format"; import type {d3Selection, d3Transition} from "../../types/types"; import {$CIRCLE, $COMMON, $TEXT} from "../config/classes"; import Options from "../config/Options/Options"; import Store from "../config/Store/Store"; import {document, window} from "../module/browser"; import Cache from "../module/Cache"; import {checkModuleImport} from "../module/error"; import {generateResize} from "../module/generator"; import { callFn, capitalize, convertInputType, extend, getOption, getRandom, hasStyle, isFunction, isObject, isString, notEmpty, sortValue } from "../module/util"; // data import dataConvert from "./data/convert"; import data from "./data/data"; import dataLoad from "./data/load"; // interactions import interaction from "./interactions/interaction"; // internals import category from "./internals/category"; // used to retrieve radar Axis name import classModule from "./internals/class"; import color from "./internals/color"; import domain from "./internals/domain"; import format from "./internals/format"; import legend from "./internals/legend"; import redraw from "./internals/redraw"; import scale from "./internals/scale"; import size from "./internals/size"; import style from "./internals/style"; import text from "./internals/text"; import title from "./internals/title"; import tooltip from "./internals/tooltip"; import transform from "./internals/transform"; import typeInternals from "./internals/type"; import shape from "./shape/shape"; /** * Internal chart class. * - Note: Instantiated internally, not exposed for public. * @class ChartInternal * @ignore * @private */ export default class ChartInternal { public api; // API interface public config; // config object public cache; // cache instance public $el; // elements public state; // state variables public charts; // all Chart instances array within page (equivalent of 'bb.instances') // data object public data = { xs: {}, targets: [] }; // Axis public axis; // Axis // scales public scale = { x: null, y: null, y2: null, subX: null, subY: null, subY2: null, zoom: null }; // original values public org = { xScale: null, xDomain: null }; // formatter function public color; public patterns; public levelColor; public point; public brush; // format function public 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: SVGElement | d3Selection | d3Transition, force?: boolean, name?: string): d3Selection { 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 = d3Select(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) as d3Selection; } return t; } beforeInit(): void { const $$ = this; $$.callPluginHook("$beforeInit"); // can do something callFn($$.config.onbeforeinit, $$.api); } afterInit(): void { const $$ = this; $$.callPluginHook("$afterInit"); // can do something callFn($$.config.onafterinit, $$.api); } init(): void { const $$ = <any>this; const {config, state, $el} = $$; const useCssRule = config.boost_useCssRule; checkModuleImport($$); 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() as number)}`; if (useCssRule) { // append style element const styleEl = document.createElement("style"); // styleEl.id = styleId; styleEl.type = "text/css"; document.head.appendChild(styleEl); state.style = { rootSelctor: `.${state.datetimeId}`, sheet: styleEl.sheet }; // used on .destroy() $el.style = styleEl; } const bindto = { element: config.bindto, classname: "bb" }; if (isObject(config.bindto)) { bindto.element = config.bindto.element || "#chart"; bindto.classname = config.bindto.classname || bindto.classname; } // select bind element $el.chart = isFunction(bindto.element.node) ? config.bindto.element : d3Select(bindto.element || []); if ($el.chart.empty()) { $el.chart = d3Select(document.body.appendChild(document.createElement("div"))); } $el.chart.html("") .classed(bindto.classname, true) .classed(state.datetimeId, useCssRule) .style("position", "relative"); $$.initParams(); $$.initToRender(); } /** * Initialize the rendering process * @param {boolean} forced Force to render process * @private */ initToRender(forced?: boolean): void { const $$ = <any>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 = window.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(): void { const $$ = <any>this; const {config, format, state} = $$; const isRotated = config.axis_rotated; // 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 ($$.hasPointType() || $$.hasLegendDefsPoint?.()) { $$.point = $$.generatePoint(); } if (state.hasAxis) { $$.initClip(); format.extraLineClasses = $$.generateExtraLineClass(); format.dataTime = config.data_xLocaltime ? d3TimeParse : d3UtcParse; format.axisTime = config.axis_x_localtime ? d3TimeFormat : d3UtcFormat; 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: string = (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); }; } state.isLegendRight = config.legend_position === "right"; state.isLegendInset = config.legend_position === "inset"; state.isLegendTop = config.legend_inset_anchor === "top-left" || config.legend_inset_anchor === "top-right"; state.isLegendLeft = config.legend_inset_anchor === "top-left" || config.legend_inset_anchor === "bottom-left"; state.rotatedPadding.top = $$.getResettedPadding(state.rotatedPadding.top); state.rotatedPadding.right = isRotated && !config.axis_x_show ? 0 : 30; state.inputType = convertInputType( config.interaction_inputType_mouse, config.interaction_inputType_touch ); } initWithData(data): void { const $$ = <any>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()); } } // -- 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; $el.svg .on("click", onclick?.bind($$.api) || null) .on(isTouch ? "touchstart" : "mouseenter", onover?.bind($$.api) || null) .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 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 $$.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(): void { const $$ = <any>this; const {hasAxis, hasRadar, hasTreemap} = $$.state; const types: string[] = []; if (hasAxis) { const shapes = ["bar", "bubble", "candlestick", "line"]; if ($$.config.bar_front) { shapes.push(shapes.shift() as string); } shapes.forEach(v => { const name = capitalize(v); if ((v === "line" && $$.hasTypeOf(name)) || $$.hasType(v)) { types.push(name); } }); } else if (hasTreemap) { types.push("Treemap"); } else if ($$.hasType("funnel")) { types.push("Funnel"); } else { const hasPolar = $$.hasType("polar"); if (!hasRadar) { types.push("Arc", "Pie"); } if ($$.hasType("gauge")) { types.push("Gauge"); } else if (hasRadar) { types.push("Radar"); } else if (hasPolar) { types.push("Polar"); } } types.forEach(v => { $$[`init${v}`](); }); notEmpty($$.config.data_labels) && !$$.hasArcType(null, ["radar"]) && $$.initText(); } /** * Set chart elements * @private */ setChartElements(): void { const $$ = this; const { $el: { chart, svg, defs, main, tooltip, legend, title, grid, needle, arcs: arc, circle: circles, bar: bars, candlestick, line: lines, area: areas, text: texts } } = $$; // public $$.api.$ = { chart, svg, defs, main, tooltip, legend, title, grid, arc, circles, bar: {bars}, candlestick, line: {lines, areas}, needle, text: {texts} }; } /** * Set background element/image * @private */ setBackground(): void { 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): void { const $$ = <any>this; const {hasAxis, hasFunnel, hasRadar, hasTreemap} = $$.state; const helper = type => $$[`updateTargetsFor${type}`]( targets.filter($$[`is${type}Type`].bind($$)) ); // Text $$.updateTargetsForText(targets); if (hasAxis) { ["bar", "candlestick", "line"].forEach(v => { const name = capitalize(v); if ((v === "line" && $$.hasTypeOf(name)) || $$.hasType(v)) { helper(name); } }); // Sub Chart $$.updateTargetsForSubchart && $$.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: boolean = false): void { const $$ = <any>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 }; Object.keys(withOptions).forEach(key => { let defVal = withOptions[key]; if (isString(defVal)) { defVal = withOptions[defVal]; } withOptions[key] = getOption(options, `with${key}`, defVal); }); return withOptions; } initialOpacity(d): null | "0" { const $$ = <any>this; const {withoutFadeIn} = $$.state; const r = $$.getBaseValue(d) !== null && withoutFadeIn[d.id] ? null : "0"; return r; } bindResize(): void { const $$ = <any>this; const {config, state} = $$; const resizeFunction = generateResize(config.resize_timer); const list: Function[] = []; list.push(() => callFn(config.onresize, $$.api)); if (config.resize_auto === true) { list.push(() => { state.resizing = true; // https://github.com/naver/billboard.js/issues/2650 if (config.legend_show) { $$.updateSizes(); $$.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 window.addEventListener("resize", $$.resizeFunction = resizeFunction); } /** * Call plugin hook * @param {string} phase The lifecycle phase * @param {Array} args Arguments * @private */ callPluginHook(phase, ...args): void { 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 ]);