UNPKG

billboard.js

Version:

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

1,125 lines (1,124 loc) • 41.5 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 { isArray, isEmpty, isFunction, isNumber, isObjectType, isString, isValue, notEmpty } from "../../module/util/type-checks.js"; import { capitalize, mergeObj, parseDate, sortValue } from "../../module/util/object.js"; import { getBoundingRect } from "../../module/util/dom.js"; import { $AXIS, $COMMON } from "../../config/classes.js"; import { KEY } from "../../module/Cache.js"; import { getScale } from "../internals/scale.js"; import AxisRenderer from "./AxisRenderer.js"; //#region src/ChartInternal/Axis/Axis.ts /** * Get the corresponding main axis id for subchart axis ids. * @param {string} id Axis id * @returns {string} Base axis id * @private */ function getBaseAxisId(id) { return id === "subX" ? "x" : id === "subY" ? "y" : id === "subY2" ? "y2" : id; } /** * Check whether the axis id belongs to subchart. * @param {string} id Axis id * @returns {boolean} Whether id is a subchart axis * @private */ function isSubAxis(id) { return /^sub/.test(id); } /** * Get tick option value for main/subchart axis. * @param {object} config Chart config * @param {string} id Axis id * @param {string} key Tick option key * @returns {boolean|number|string|Array|object|function|null|undefined} Tick option value * @private */ function getAxisTickOption(config, id, key) { const type = getBaseAxisId(id); const subValue = config[`subchart_axis_${type}_tick_${key}`]; return isSubAxis(id) && subValue !== void 0 ? subValue : config[`axis_${type}_tick_${key}`]; } /** * Sample representative tick nodes to avoid N forced reflows in getMaxTickSize * @param {SVGTextElement[]} nodes All tick text nodes * @returns {SVGTextElement[]} Sampled subset: first, last, longest, and middle nodes * @private */ function _sampleTickNodes(nodes) { const sampled = [nodes[0], nodes[nodes.length - 1]]; let maxLen = 0; let longestNode = null; for (const node of nodes) { const len = node.textContent?.length ?? 0; if (len > maxLen) { maxLen = len; longestNode = node; } } if (longestNode && !sampled.includes(longestNode)) sampled.push(longestNode); const mid = nodes[Math.floor(nodes.length / 2)]; if (!sampled.includes(mid)) sampled.push(mid); return sampled; } const MAX_TICK_MEASURE_VALUES = 50; const TICK_WIDTH_FALLBACK = Symbol("tickWidthFallback"); /** * Get SVG tick line stroke width. * @param {object} tickNodes Tick node selection * @returns {number} Tick line stroke width * @private */ function _getTickLineWidth(tickNodes) { const line = tickNodes.select("line").node(); const strokeWidth = line?.ownerDocument?.defaultView?.getComputedStyle ? parseFloat(line.ownerDocument.defaultView.getComputedStyle(line).strokeWidth) : parseFloat(line?.getAttribute?.("stroke-width")); return Number.isFinite(strokeWidth) && strokeWidth > 0 ? strokeWidth : 1; } /** * Check whether adjacent tick line intervals overlap on the rendered axis. * @param {object} axis Axis renderer * @param {Array} tickValues Sorted tick values * @param {number} tickLineWidth Tick line stroke width * @returns {boolean} Whether tick lines should be culled with tick text * @private */ function _hasOverlappedTickLineIntervals(axis, tickValues, tickLineWidth) { const scale = axis?.scale?.(); if (!scale || tickValues.length < 2) return false; const halfWidth = Math.max(1, tickLineWidth) / 2; const positions = tickValues.map((value) => +scale(value)).filter(Number.isFinite).sort((a, b) => a - b); if (positions.length < 2) return false; let previousEnd = positions[0] + halfWidth; for (let i = 1; i < positions.length; i++) { const start = positions[i] - halfWidth; const end = positions[i] + halfWidth; if (start <= previousEnd + 1) return true; previousEnd = Math.max(previousEnd, end); } return false; } /** * Sample representative tick values before creating dummy tick DOM nodes. * @param {Array} values All tick values * @param {function} format Tick format function * @returns {Array} Sampled tick values * @private */ function _sampleTickValues(values, format) { if (values.length <= MAX_TICK_MEASURE_VALUES) return values; const sampled = /* @__PURE__ */ new Map(); const add = (index) => { if (index >= 0 && index < values.length) sampled.set(index, values[index]); }; add(0); add(values.length - 1); add(Math.floor(values.length / 2)); const step = Math.max(1, Math.floor(values.length / 47)); let maxLength = -1; let maxIndex = 0; for (let i = 0; i < values.length; i += step) { add(i); const value = values[i]; const text = format ? format(value) : value; const length = Array.isArray(text) ? text.join("").length : String(text ?? "").length; if (length > maxLength) { maxLength = length; maxIndex = i; } } add(maxIndex); return Array.from(sampled.keys()).sort((a, b) => a - b).map((index) => sampled.get(index)); } /** * Get compact tick values for cache fingerprinting. * @param {Array|function} values Tick values * @returns {Array|object|string} Cache fingerprint value * @private */ function _getTickValuesCacheValue(values) { if (isFunction(values)) return _getCacheReferenceId(values); if (!Array.isArray(values) || values.length <= MAX_TICK_MEASURE_VALUES) return values; return { length: values.length, first: values[0], middle: values[Math.floor(values.length / 2)], last: values[values.length - 1] }; } /** * Get compact tick width fallback. * @param {Array} ticks Tick width array * @returns {number|undefined} Fallback width * @private */ function _getTickWidthFallback(ticks) { return ticks[TICK_WIDTH_FALLBACK]; } /** * Set compact tick width fallback. * @param {Array} ticks Tick width array * @param {number} width Fallback width * @private */ function _setTickWidthFallback(ticks, width) { if (isNumber(width)) Object.defineProperty(ticks, TICK_WIDTH_FALLBACK, { configurable: true, value: width, writable: true }); else delete ticks[TICK_WIDTH_FALLBACK]; } /** * Reset tick widths and compact metadata. * @param {Array} ticks Tick width array * @private */ function _clearTickWidths(ticks) { ticks.length = 0; _setTickWidthFallback(ticks); } /** * Store large uniform tick widths without filling the array. * @param {Array} ticks Tick width array * @param {number} length Tick count * @param {number} width Uniform fallback width * @private */ function _compactTickWidths(ticks, length, width) { _clearTickWidths(ticks); ticks.length = length; _setTickWidthFallback(ticks, width); } /** * Clone tick widths while preserving sparse compact storage. * @param {Array} ticks Tick width array * @returns {Array} Cloned tick width array * @private */ function _cloneTickWidths(ticks) { const clone = []; clone.length = ticks.length; Object.keys(ticks).forEach((key) => { const index = +key; clone[index] = ticks[index]; }); _setTickWidthFallback(clone, _getTickWidthFallback(ticks)); return clone; } /** * Restore cached tick widths into current mutable state. * @param {Array} target Current tick width array * @param {Array} source Cached tick width array * @private */ function _restoreTickWidths(target, source) { _clearTickWidths(target); target.length = source.length; Object.keys(source).forEach((key) => { const index = +key; target[index] = source[index]; }); _setTickWidthFallback(target, _getTickWidthFallback(source)); } /** * Get a tick width from dense or compact storage. * @param {Array} ticks Tick width array * @param {number} index Tick index * @param {number} fallback Fallback width * @returns {number} Tick width * @private */ function _getTickWidth(ticks, index, fallback) { const value = ticks[index]; if (isNumber(value)) return value; const numericValue = Number(value); return Number.isFinite(numericValue) ? numericValue : fallback; } const cacheReferenceIds = /* @__PURE__ */ new WeakMap(); let cacheReferenceUid = 0; /** * Get stable identity for non-serializable cache inputs. * @param {function|object|string|number|boolean|null|undefined} value Value to identify * @returns {string} Stable reference id * @private */ function _getCacheReferenceId(value) { if (!value || !/^(function|object)$/.test(typeof value)) return `${typeof value}:${String(value)}`; let id = cacheReferenceIds.get(value); if (!id) { id = ++cacheReferenceUid; cacheReferenceIds.set(value, id); } return `${typeof value}:${id}`; } /** * Stringify axis measurement inputs for cache matching. * @param {Date|Array|function|object|string|number|boolean|null|undefined} value Value to stringify * @returns {string} Stringified value * @private */ function _stringifyCacheValue(value) { if (value instanceof Date) return `date:${+value}`; else if (Array.isArray(value)) return `[${value.map((v) => _stringifyCacheValue(v)).join(",")}]`; else if (value && typeof value === "object") return `{${Object.keys(value).sort().map((key) => `${key}:${_stringifyCacheValue(value[key])}`).join(",")}}`; else if (typeof value === "function") return _getCacheReferenceId(value); return `${typeof value}:${String(value)}`; } /** * Clone array/date cache values before storing state snapshots. * @param {Date|Array|object|string|number|boolean|null|undefined} value Value to clone * @returns {Date|Array|object|string|number|boolean|null|undefined} Cloned value * @private */ function _cloneCacheValue(value) { return value instanceof Date ? /* @__PURE__ */ new Date(+value) : Array.isArray(value) ? value.map((v) => _cloneCacheValue(v)) : value; } /** * Clone tick measurement state without mutable array side effects. * @param {object} tickSize Tick measurement state * @returns {object} Cloned measurement state * @private */ function _cloneMaxTickSize(tickSize) { return { width: tickSize.width, height: tickSize.height, ticks: tickSize.ticks && _cloneTickWidths(tickSize.ticks), clipPath: tickSize.clipPath, domain: _cloneCacheValue(tickSize.domain) }; } /** * Restore cached tick measurement into current mutable state. * @param {object} target Current tick measurement state * @param {object} source Cached tick measurement state * @returns {object} Current tick measurement state * @private */ function _restoreMaxTickSize(target, source) { target.width = source.width; target.height = source.height; target.clipPath = source.clipPath; target.domain = _cloneCacheValue(source.domain); if (target.ticks && source.ticks) _restoreTickWidths(target.ticks, source.ticks); return target; } var Axis_default = { getAxisInstance: function() { return this.axis || new Axis(this); } }; var Axis = class { owner; x; subX; subY; subY2; y; y2; axesList = {}; tick = { x: null, y: null, y2: null }; xs = []; orient = { x: "bottom", y: "left", y2: "right", subX: "bottom", subY: "left", subY2: "right" }; constructor(owner) { this.owner = owner; this.setOrient(); } getAxisClassName(id) { return `${$AXIS.axis} ${$AXIS[`axis${capitalize(id)}`]}`; } isHorizontal($$, forHorizontal) { const isRotated = $$.config.axis_rotated; return forHorizontal ? isRotated : !isRotated; } isCategorized() { const { config, state } = this.owner; return config.axis_x_type.indexOf("category") >= 0 || state.hasRadar; } isCustomX() { const { config } = this.owner; return !this.isTimeSeries() && (config.data_x || notEmpty(config.data_xs)); } isTimeSeries(id = "x") { return this.owner.config[`axis_${id}_type`] === "timeseries"; } isLog(id = "x") { return this.owner.config[`axis_${id}_type`] === "log"; } isTimeSeriesY() { return this.isTimeSeries("y"); } getAxisType(id = "x") { let type = "linear"; if (this.isTimeSeries(id)) type = this.owner.config.axis_x_localtime ? "time" : "utc"; else if (this.isLog(id)) type = "log"; return type; } /** * Get extent value * @returns {Array} default extent * @private */ getExtent() { const $$ = this.owner; const { config, scale } = $$; let extent = config.axis_x_extent; if (extent) { if (isFunction(extent)) extent = extent.bind($$.api)($$.getXDomain($$.data.targets), scale.subX); else if (this.isTimeSeries() && extent.every(isNaN)) { const fn = parseDate.bind($$); extent = extent.map((v) => scale.subX(fn(v))); } } return extent; } init() { const $$ = this.owner; const { config, $el: { main, axis }, state: { clip } } = $$; const target = ["x", "y"]; config.axis_y2_show && target.push("y2"); target.forEach((v) => { const classAxis = this.getAxisClassName(v); axis[v] = main.append("g").attr("class", classAxis).attr("clip-path", () => { let res = null; if (v === "x") res = clip.pathXAxis; else if (v === "y") res = clip.pathYAxis; return res; }).attr("transform", $$.getTranslate(v)).style("visibility", config[`axis_${v}_show`] ? null : "hidden"); this.generateAxes(v); }); } /** * Set axis orient according option value * @private */ setOrient() { const { axis_rotated: isRotated, axis_y_inner: yInner, axis_y2_inner: y2Inner } = this.owner.config; this.orient = { x: isRotated ? "left" : "bottom", y: isRotated ? yInner ? "top" : "bottom" : yInner ? "right" : "left", y2: isRotated ? y2Inner ? "bottom" : "top" : y2Inner ? "left" : "right", subX: isRotated ? "left" : "bottom", subY: isRotated ? yInner ? "top" : "bottom" : yInner ? "right" : "left", subY2: isRotated ? y2Inner ? "bottom" : "top" : y2Inner ? "left" : "right" }; } /** * Generate axes * It's used when axis' axes option is set * @param {string} id Axis id * @private */ generateAxes(id) { const $$ = this.owner; const { config } = $$; const axes = []; const axesConfig = config[`axis_${id}_axes`]; const isRotated = config.axis_rotated; let orient; if (id === "x") orient = isRotated ? "left" : "bottom"; else if (id === "y") orient = isRotated ? "bottom" : "left"; else if (id === "y2") orient = isRotated ? "top" : "right"; if (axesConfig.length) axesConfig.forEach((v) => { const tick = v.tick || {}; const scale = $$.scale[id].copy(); v.domain && scale.domain(v.domain); const axis = new AxisRenderer({ outerTick: tick.outer !== false, noTransition: false, config, id, isSubAxes: true, owner: $$ }).scale(scale).orient(orient).tickFormat(isFunction(tick.format) ? tick.format.bind($$.api) : ((x) => x)); isValue(tick.count) && axis.ticks(tick.count); tick.values && axis.tickValues(tick.values); axes.push(axis); }); this.axesList[id] = axes; } /** * Update axes nodes * @private */ updateAxes() { const $$ = this.owner; const { config, $el: { main }, $T } = $$; Object.keys(this.axesList).forEach((id) => { const axesConfig = config[`axis_${id}_axes`]; const scale = $$.scale[id].copy(); const range = scale.range(); this.axesList[id].forEach((v, i) => { const axisRange = v.scale().range(); if (!range.every((v, i) => v === axisRange[i])) v.scale().range(range); const className = `${this.getAxisClassName(id)}-${i + 1}`; let g = main.select(`.${className.replace(/\s/, ".")}`); if (g.empty()) { g = main.append("g").attr("class", className).style("visibility", config[`axis_${id}_show`] ? null : "hidden"); v.create(g); } else { axesConfig[i].domain && scale.domain(axesConfig[i].domain); v.scale(scale).create($T(g)); } g.attr("transform", $$.getTranslate(id, i + 1)); }); }); } /** * Set Axis & tick values * called from: updateScales() * @param {string} id Axis id string * @param {d3Scale} scale Scale * @param {boolean} outerTick If show outer tick * @param {boolean} noTransition If with no transition * @private */ setAxis(id, scale, outerTick, noTransition) { const $$ = this.owner; const type = getBaseAxisId(id); if (!isSubAxis(id)) this.tick[type] = this.getTickValues(type); this[id] = this.getAxis(id, scale, outerTick, id === "x" && ($$.scale.zoom || $$.config.subchart_show || $$.state.resizing) ? true : noTransition); } getAxis(id, scale, outerTick, noTransition, noTickTextRotate) { const $$ = this.owner; const { config } = $$; const isX = /^(x|subX)$/.test(id); const type = getBaseAxisId(id); const isCategory = isX && this.isCategorized(); const orient = this.orient[id]; const tickTextRotate = noTickTextRotate ? 0 : $$.getAxisTickRotate(type); let tickFormat; if (isX) tickFormat = id === "subX" ? $$.format.subXAxisTick : $$.format.xAxisTick; else { const fn = isSubAxis(id) ? config[`subchart_axis_${type}_tick_format`] || config[`axis_${type}_tick_format`] : config[`axis_${type}_tick_format`]; if (isFunction(fn)) tickFormat = fn.bind($$.api); } let tickValues = isSubAxis(id) ? this.getTickValues(type, true) : this.tick[type]; const axisParams = mergeObj({ outerTick, noTransition, config, id, tickTextRotate, owner: $$ }, isX && { isCategory, isInverted: config.axis_x_inverted, tickMultiline: config.axis_x_tick_multiline, tickWidth: config.axis_x_tick_width, tickTitle: isCategory && config.axis_x_tick_tooltip && $$.api.categories(), orgXScale: $$.scale.x }); if (!isX) axisParams.tickStepSize = config[`axis_${type}_tick_stepSize`]; const axis = new AxisRenderer(axisParams).scale(isX && $$.scale.zoom || scale).orient(orient); if (isX && this.isTimeSeries() && tickValues && !isFunction(tickValues)) { const fn = parseDate.bind($$); tickValues = tickValues.map((v) => fn(v)); } else if (!isX && this.isTimeSeriesY()) { axis.ticks(config.axis_y_tick_time_value); tickValues = null; } tickValues && axis.tickValues(tickValues); axis.tickFormat(tickFormat || !isX && $$.isStackNormalized() && $$.hasAxisGroupedData(type) && ((x) => `${x}%`)); if (isCategory) { axis.tickCentered(config.axis_x_tick_centered); if (isEmpty(config.axis_x_tick_culling)) config.axis_x_tick_culling = false; } const tickCount = getAxisTickOption(config, id, "count"); tickCount && axis.ticks(tickCount); return axis; } updateXAxisTickValues(targets, axis) { const $$ = this.owner; const { config } = $$; const fit = config.axis_x_tick_fit; const generateValues = (countOption = config.axis_x_tick_count) => { let count = countOption; let values; if (fit) { values = $$.mapTargetsToUniqueXs(targets); if (this.isCategorized() && count > values.length) count = values.length; values = this.generateTickValues(values, count, this.isTimeSeries()); } return values; }; const values = generateValues(); if (axis) axis.tickValues(values); else if (this.x) { const subTickValues = this.getTickValues("x", true); const subTickCount = getAxisTickOption(config, "subX", "count"); const subValues = subTickValues ?? (subTickCount !== config.axis_x_tick_count ? generateValues(subTickCount) : values); this.x.tickValues(values); this.subX?.tickValues(subValues); } return values; } getId(id) { const { config, scale } = this.owner; let axis = config.data_axes[id]; if (!axis || !scale[axis]) axis = "y"; return axis; } getXAxisTickFormat(forSubchart) { const $$ = this.owner; const { config, format } = $$; const tickFormat = forSubchart ? config.subchart_axis_x_tick_format || config.axis_x_tick_format : config.axis_x_tick_format; const isTimeSeries = this.isTimeSeries(); const isCategorized = this.isCategorized(); let currFormat; if (tickFormat) { if (isFunction(tickFormat)) currFormat = tickFormat.bind($$.api); else if (isTimeSeries) currFormat = (date) => date ? format.axisTime(tickFormat)(date) : ""; } else currFormat = isTimeSeries ? format.defaultAxisTime : isCategorized ? $$.categoryName : (v) => v < 0 ? v.toFixed(0) : v; return isFunction(currFormat) ? (v) => currFormat.apply($$, isCategorized ? [v, $$.categoryName(v)] : [v]) : currFormat; } getTickValues(id, isSub = false) { const $$ = this.owner; const tickValues = isSub ? getAxisTickOption($$.config, `sub${capitalize(id)}`, "values") : $$.config[`axis_${id}_tick_values`]; const values = isFunction(tickValues) ? tickValues.call($$.api) : tickValues; const axis = $$[`${id}Axis`]; return isSub ? values ?? void 0 : values || (axis ? axis.tickValues() : void 0); } getLabelOptionByAxisId(id) { return this.owner.config[`axis_${id}_label`]; } getLabelText(id) { const option = this.getLabelOptionByAxisId(id); return isString(option) ? option : option ? option.text : null; } setLabelText(id, text) { const { config } = this.owner; const option = this.getLabelOptionByAxisId(id); if (isString(option)) config[`axis_${id}_label`] = text; else if (option) option.text = text; } getLabelPosition(id, defaultPosition) { const isRotated = this.owner.config.axis_rotated; const option = this.getLabelOptionByAxisId(id); const position = isObjectType(option) && option.position ? option.position : defaultPosition[+!isRotated]; const has = (v) => !!~position.indexOf(v); return { isInner: has("inner"), isOuter: has("outer"), isLeft: has("left"), isCenter: has("center"), isRight: has("right"), isTop: has("top"), isMiddle: has("middle"), isBottom: has("bottom") }; } getAxisLabelPosition(id) { return this.getLabelPosition(id, id === "x" ? ["inner-top", "inner-right"] : ["inner-right", "inner-top"]); } xForAxisLabel(id) { const $$ = this.owner; const { state: { width, height } } = $$; const position = this.getAxisLabelPosition(id); let x = position.isMiddle ? -height / 2 : 0; if (this.isHorizontal($$, id !== "x")) x = position.isLeft ? 0 : position.isCenter ? width / 2 : width; else if (position.isBottom) x = -height; return x; } textAnchorForAxisLabel(id) { const $$ = this.owner; const position = this.getAxisLabelPosition(id); let anchor = position.isMiddle ? "middle" : "end"; if (this.isHorizontal($$, id !== "x")) anchor = position.isLeft ? "start" : position.isCenter ? "middle" : "end"; else if (position.isBottom) anchor = "start"; return anchor; } dxForAxisLabel(id) { const $$ = this.owner; const position = this.getAxisLabelPosition(id); let dx = position.isBottom ? "0.5em" : "0"; if (this.isHorizontal($$, id !== "x")) dx = position.isLeft ? "0.5em" : position.isRight ? "-0.5em" : "0"; else if (position.isTop) dx = "-0.5em"; return dx; } dyForAxisLabel(id) { const $$ = this.owner; const { config } = $$; const isRotated = config.axis_rotated; const isInner = this.getAxisLabelPosition(id).isInner; const tickRotate = config[`axis_${id}_tick_rotate`] ? $$.getHorizontalAxisHeight(id) : 0; const { width: maxTickWidth } = this.getMaxTickSize(id); let dy; if (id === "x") { const xHeight = config.axis_x_height; if (isRotated) dy = isInner ? "1.2em" : -25 - maxTickWidth; else if (isInner) dy = "-0.5em"; else if (xHeight) dy = xHeight - 10; else if (tickRotate) dy = tickRotate - 10; else dy = "3em"; } else { dy = { y: [ "-0.5em", 10, "3em", "1.2em", 10 ], y2: [ "1.2em", -20, "-2.2em", "-0.5em", 15 ] }[id]; if (isRotated) { if (isInner) dy = dy[0]; else if (tickRotate) dy = tickRotate * (id === "y2" ? -1 : 1) - dy[1]; else dy = dy[2]; } else dy = isInner ? dy[3] : (dy[4] + (config[`axis_${id}_inner`] ? 0 : maxTickWidth + dy[4])) * (id === "y" ? -1 : 1); } return dy; } getTickFormatCacheValue(id) { const $$ = this.owner; const { config } = $$; return id === "x" ? { format: config.axis_x_tick_format, type: config.axis_x_type, localtime: config.axis_x_localtime, categories: config.axis_x_categories } : { format: config[`axis_${id}_tick_format`], normalized: $$.isStackNormalized(), grouped: $$.hasAxisGroupedData(id), type: config[`axis_${id}_type`] }; } getMaxTickSizeFingerprint(id, scale, domain, axis, tickRotate, withoutRecompute) { const { config, state } = this.owner; const isX = id === "x"; const configPrefix = isX ? "axis_x" : `axis_${id}`; const tickValues = axis.tickValues(); return _stringifyCacheValue({ id, withoutRecompute: !!withoutRecompute, dataGeneration: state.dataGeneration, size: [state.current.width, state.current.height], range: scale.range?.(), domain, type: scale.type, orient: this.orient[id], axisRotated: config.axis_rotated, evalTextSize: config.axis_evalTextSize, format: this.getTickFormatCacheValue(id), ticks: { values: _getTickValuesCacheValue(tickValues), rawValues: config[`${configPrefix}_tick_values`], arguments: axis.ticks(), count: config[`${configPrefix}_tick_count`], rotate: tickRotate, show: config[`${configPrefix}_tick_show`], textShow: config[`${configPrefix}_tick_text_show`], textPosition: config[`${configPrefix}_tick_text_position`], inner: config[`${configPrefix}_tick_inner`], culling: config[`${configPrefix}_tick_culling`], cullingMax: config[`${configPrefix}_tick_culling_max`], cullingLines: config[`${configPrefix}_tick_culling_lines`], cullingReverse: config[`${configPrefix}_tick_culling_reverse`], stepSize: !isX && config[`${configPrefix}_tick_stepSize`], timeValue: !isX && config[`${configPrefix}_tick_time_value`], fit: isX && config.axis_x_tick_fit, autorotate: isX && config.axis_x_tick_autorotate, centered: isX && config.axis_x_tick_centered, inverted: isX && config.axis_x_inverted, multiline: isX && config.axis_x_tick_multiline, width: isX && config.axis_x_tick_width } }); } /** * Get max tick size * @param {string} id axis id string * @param {boolean} withoutRecompute wheather or not to recompute * @returns {object} {width, height} * @private */ getMaxTickSize(id, withoutRecompute) { const $$ = this.owner; const { config, state, $el: { svg, chart } } = $$; const { current, resizing } = state; const currentTickMax = current.maxTickSize[id]; const cacheKey = `${KEY.maxTickSize}_${id}_${!!withoutRecompute}`; const cached = $$.cache.get(cacheKey); if (cached && cached.generation === state.redrawGeneration) return currentTickMax; const configPrefix = `axis_${id}`; const max = { width: 0, height: 0 }; let fingerprint; if (resizing || withoutRecompute || !config[`${configPrefix}_show`] || currentTickMax.width > 0 && $$.filterTargetsToShow().length === 0) return currentTickMax; if ((svg || config.render_mode === "canvas") && $$.scale[id]?.copy) { const isYAxis = /^y2?$/.test(id); const targetsToShow = $$.getTargetsToShow(); const scale = $$.scale[id].copy().domain($$[`get${isYAxis ? "Y" : "X"}Domain`](targetsToShow, id)); const domain = scale.domain(); const isDomainSame = domain[0] === domain[1] && domain.every((v) => v > 0); const isCurrentMaxTickDomainSame = isArray(currentTickMax.domain) && currentTickMax.domain[0] === currentTickMax.domain[1] && currentTickMax.domain.every((v) => v > 0); if (isDomainSame || isCurrentMaxTickDomainSame) return currentTickMax; else currentTickMax.domain = domain; if (!isYAxis) _clearTickWidths(currentTickMax.ticks); const axis = this.getAxis(id, scale, false, false, true); const tickRotate = config[`${configPrefix}_tick_rotate`]; const tickCount = config[`${configPrefix}_tick_count`]; if (!config[`${configPrefix}_tick_values`] && tickCount) axis.tickValues(this.generateTickValues(domain, tickCount, isYAxis ? this.isTimeSeriesY() : this.isTimeSeries())); !isYAxis && this.updateXAxisTickValues(targetsToShow, axis); fingerprint = this.getMaxTickSizeFingerprint(id, scale, domain, axis, tickRotate, withoutRecompute); if (cached?.fingerprint === fingerprint) { $$.cache.add(cacheKey, { ...cached, generation: state.redrawGeneration }); return _restoreMaxTickSize(currentTickMax, cached.value); } const originalTickValues = axis.tickValues(); const hasLargeTickValues = !isYAxis && Array.isArray(originalTickValues) && originalTickValues.length > MAX_TICK_MEASURE_VALUES; hasLargeTickValues && axis.tickValues(_sampleTickValues(originalTickValues, axis.tickFormat())); const dummy = chart.append("svg").style("visibility", "hidden").style("position", "fixed").style("top", "0").style("left", "0"); const g = dummy.append("g").attr("class", `${$AXIS[`axis${capitalize(id)}`]} ${$COMMON.dummy}`); axis.create(g); const { sizeFor1Char } = g.node(); const textSelection = dummy.selectAll("text").attr("transform", isNumber(tickRotate) ? `rotate(${tickRotate})` : null); const measuredTickCount = hasLargeTickValues ? originalTickValues.length : textSelection.size(); if (sizeFor1Char) textSelection.each(function(d, i) { const width = this.textContent.length * sizeFor1Char.w; const height = sizeFor1Char.h; max.width = Math.max(max.width, width); max.height = Math.max(max.height, height); if (!isYAxis) currentTickMax.ticks[i] = width; }); else { const textNodes = []; textSelection.each(function() { textNodes.push(this); }); (textNodes.length <= 5 ? textNodes : _sampleTickNodes(textNodes)).map((node) => getBoundingRect(node, true)).forEach((dim) => { max.width = Math.max(max.width, dim.width); max.height = Math.max(max.height, dim.height); }); if (!isYAxis) for (let i = 0; i < textNodes.length; i++) currentTickMax.ticks[i] = max.width; } if (!isYAxis && hasLargeTickValues) _compactTickWidths(currentTickMax.ticks, measuredTickCount, max.width); dummy.remove(); } Object.keys(max).forEach((key) => { if (max[key] > 0) currentTickMax[key] = max[key]; }); $$.cache.add(cacheKey, { fingerprint, generation: state.redrawGeneration, value: _cloneMaxTickSize(currentTickMax) }); return currentTickMax; } getXAxisTickTextY2Overflow(defaultPadding) { const $$ = this.owner; const { axis, config, state: { current, isLegendRight, legendItemWidth } } = $$; const xAxisTickRotate = $$.getAxisTickRotate("x"); const positiveRotation = xAxisTickRotate > 0 && xAxisTickRotate < 90; if ((axis.isCategorized() || axis.isTimeSeries()) && config.axis_x_tick_fit && (!config.axis_x_tick_culling || isEmpty(config.axis_x_tick_culling)) && !config.axis_x_tick_multiline && positiveRotation) { const y2AxisWidth = config.axis_y2_show && current.maxTickSize.y2.width || 0; const legendWidth = isLegendRight && legendItemWidth || 0; const widthWithoutCurrentPaddingLeft = current.width - $$.getCurrentPaddingByDirection("left"); const maxOverflow = this.getXAxisTickMaxOverflow(xAxisTickRotate, widthWithoutCurrentPaddingLeft - defaultPadding) - y2AxisWidth - legendWidth; const xAxisTickTextY2Overflow = Math.max(0, maxOverflow) + defaultPadding; return Math.min(xAxisTickTextY2Overflow, widthWithoutCurrentPaddingLeft / 2); } return 0; } getXAxisTickMaxOverflow(xAxisTickRotate, widthWithoutCurrentPaddingLeft) { const $$ = this.owner; const { axis, config, state } = $$; const isTimeSeries = axis.isTimeSeries(); const tickTextWidths = state.current.maxTickSize.x.ticks; const tickCount = tickTextWidths.length; const fallbackTickTextWidth = _getTickWidthFallback(tickTextWidths) ?? state.current.maxTickSize.x.width; const { left, right } = state.axis.x.padding; let maxOverflow = 0; const remaining = tickCount - (isTimeSeries && config.axis_x_tick_fit ? .5 : 0); for (let i = 0; i < tickCount; i++) { const tickIndex = i + 1; const rotatedTickTextWidth = Math.cos(Math.PI * xAxisTickRotate / 180) * _getTickWidth(tickTextWidths, i, fallbackTickTextWidth); const ticksBeforeTickText = tickIndex - (isTimeSeries ? 1 : .5) + left; if (ticksBeforeTickText <= 0) continue; const tickLength = (widthWithoutCurrentPaddingLeft - rotatedTickTextWidth) / ticksBeforeTickText; const remainingTicks = remaining - tickIndex; const paddingRightLength = right * tickLength; const remainingTickWidth = remainingTicks * tickLength + paddingRightLength; const overflow = rotatedTickTextWidth - tickLength / 2 - remainingTickWidth; maxOverflow = Math.max(maxOverflow, overflow); } const filteredTargets = $$.getTargetsToShow(); let tickOffset = 0; if (!isTimeSeries && config.axis_x_tick_count <= filteredTargets.length && filteredTargets[0].values.length) { const scale = getScale($$.axis.getAxisType("x"), 0, widthWithoutCurrentPaddingLeft - maxOverflow).domain([left * -1, $$.getXDomainMax($$.data.targets) + 1 + right]); tickOffset = (scale(1) - scale(0)) / 2; } return maxOverflow + tickOffset; } /** * Update axis label text * @param {boolean} withTransition Weather update with transition * @private */ updateLabels(withTransition) { const { config, $el: { main }, $T } = this.owner; const isRotated = config.axis_rotated; [ "x", "y", "y2" ].forEach((id) => { const text = this.getLabelText(id); const selector = `axis${capitalize(id)}`; const classLabel = $AXIS[`${selector}Label`]; if (text) { let axisLabel = main.select(`text.${classLabel}`); if (axisLabel.empty()) axisLabel = main.select(`g.${$AXIS[selector]}`).insert("text", ":first-child").attr("class", classLabel).attr("transform", ["rotate(-90)", null][id === "x" ? +!isRotated : +isRotated]).style("text-anchor", () => this.textAnchorForAxisLabel(id)); $T(axisLabel, withTransition).attr("x", () => this.xForAxisLabel(id)).attr("dx", () => this.dxForAxisLabel(id)).attr("dy", () => this.dyForAxisLabel(id)).text(text); } }); } /** * Get axis padding value * @param {number|object} padding Padding object * @param {string} key Key string of padding * @param {Date|number} defaultValue Default value * @param {number} domainLength Domain length * @returns {number} Padding value in scale * @private */ getPadding(padding, key, defaultValue, domainLength) { const p = isNumber(padding) ? padding : padding[key]; if (!isValue(p)) return defaultValue; return this.owner.convertPixelToScale(/(bottom|top)/.test(key) ? "y" : "x", p, domainLength); } generateTickValues(values, tickCount, forTimeSeries) { let tickValues = values; if (tickCount) { const targetCount = isFunction(tickCount) ? tickCount() : tickCount; if (targetCount === 1) tickValues = [values[0]]; else if (targetCount === 2) tickValues = [values[0], values[values.length - 1]]; else if (targetCount > 2) { const isCategorized = this.isCategorized(); const count = targetCount - 2; const start = values[0]; const end = values[values.length - 1]; const interval = (end - start) / (count + 1); let tickValue; tickValues = [start]; for (let i = 0; i < count; i++) { tickValue = +start + interval * (i + 1); tickValues.push(forTimeSeries ? new Date(tickValue) : isCategorized ? Math.round(tickValue) : tickValue); } tickValues.push(end); } } if (!forTimeSeries) tickValues = tickValues.sort((a, b) => a - b); return tickValues; } generateTransitions(withTransition) { const { $el: { axis }, $T } = this.owner; const [axisX, axisY, axisY2, axisSubX, axisSubY, axisSubY2] = [ "x", "y", "y2", "subX", "subY", "subY2" ].map((v) => $T(axis[v], withTransition)); return { axisX, axisY, axisY2, axisSubX, axisSubY, axisSubY2 }; } redraw(transitions, isHidden, isInit) { const { config, state, $el } = this.owner; const opacity = isHidden ? "0" : null; [ "x", "y", "y2", "subX", "subY", "subY2" ].forEach((id) => { const axis = this[id]; const $axis = $el.axis[id]; if (axis && $axis) { if (!isInit && !config.transition_duration) axis.config.withoutTransition = true; $axis.style("opacity", opacity); axis.create(transitions[`axis${capitalize(id)}`]); } }); this.updateAxes(); !state.rendered && config.axis_tooltip && this.setAxisTooltip(); } /** * Synchronize axis domains and tick values. * @param {Array} targetsToShow targets data to be shown * @param {object} wth option object * @param {object} flow flow object * @private */ syncAxisDomains(targetsToShow, wth, flow) { const $$ = this.owner; const { config, scale, $el } = $$; const hasZoom = !!scale.zoom; let xDomainForZoom; if (!hasZoom && this.isCategorized() && targetsToShow.length === 0 && $el.axis.x) scale.x.domain([0, $el.axis.x.selectAll(".tick").size()]); if (scale.x && targetsToShow.length) { !hasZoom && $$.updateXDomain(targetsToShow, wth.UpdateXDomain, wth.UpdateOrgXDomain, wth.TrimXDomain); if (!config.axis_x_tick_values) this.updateXAxisTickValues(targetsToShow); } else if (this.x) { this.x.tickValues([]); this.subX?.tickValues([]); } if (config.zoom_rescale && !flow) xDomainForZoom = scale.x.orgDomain(); ["y", "y2"].forEach((key) => { const prefix = `axis_${key}_`; const axisScale = scale[key]; if (axisScale) { const tickValues = config[`${prefix}tick_values`]; const tickCount = config[`${prefix}tick_count`]; axisScale.domain($$.getYDomain(targetsToShow, key, xDomainForZoom)); if (!tickValues && tickCount) { const axis = $$.axis[key]; const domain = axisScale.domain(); axis.tickValues(this.generateTickValues(domain, domain.every((v) => v === 0) ? 1 : tickCount, this.isTimeSeriesY())); } } }); if (wth.Y) { const updateSubDomain = () => { scale.subY?.domain($$.getYDomain(targetsToShow, "y")); scale.subY2?.domain($$.getYDomain(targetsToShow, "y2")); ["y", "y2"].forEach((key) => { const subAxisId = key === "y2" ? "subY2" : "subY"; const axisScale = scale[subAxisId]; const axis = $$.axis[subAxisId]; const tickValues = this.getTickValues(key, true); const tickCount = getAxisTickOption(config, subAxisId, "count"); if (!axisScale || !axis || tickValues || !tickCount) return; const domain = axisScale.domain(); axis.tickValues(this.generateTickValues(domain, domain.every((v) => v === 0) ? 1 : tickCount, this.isTimeSeriesY())); }); }; config.subchart_show ? $$.withSubchartTypeContext(updateSubDomain) : updateSubDomain(); } } /** * Redraw axis * @param {Array} targetsToShow targets data to be shown * @param {object} wth option object * @param {d3.Transition} transitions Transition object * @param {object} flow flow object * @param {boolean} isInit called from initialization * @private */ redrawAxis(targetsToShow, wth, transitions, flow, isInit) { const $$ = this.owner; this.syncAxisDomains(targetsToShow, wth, flow); this.redraw(transitions, $$.hasArcType(), isInit); this.updateLabels(wth.Transition); if ((wth.UpdateXDomain || wth.UpdateXAxis || wth.Y) && targetsToShow.length) this.setCulling(); } /** * Set manual culling * @private */ setCulling() { const { config, state: { clip, current }, $el } = this.owner; [ "subX", "x", "y", "y2", "subY", "subY2" ].forEach((type) => { const axis = $el.axis[type]; const toCull = getAxisTickOption(config, type, "culling"); if (axis && toCull) { const tickNodes = axis.selectAll(".tick"); const tickValues = sortValue(tickNodes.data(), !getAxisTickOption(config, type, "culling_reverse")); const tickSize = tickValues.length; const cullingMax = getAxisTickOption(config, type, "culling_max"); const cullTickLine = !getAxisTickOption(config, type, "culling_lines") || _hasOverlappedTickLineIntervals(this[type], tickValues, _getTickLineWidth(tickNodes)); let intervalForCulling; if (tickSize) { for (let i = 1; i < tickSize; i++) if (tickSize / i < cullingMax) { intervalForCulling = i; break; } intervalForCulling = intervalForCulling ?? tickSize; const tickIndexMap = /* @__PURE__ */ new Map(); for (let i = 0; i < tickValues.length; i++) tickIndexMap.set(tickValues[i], i); tickNodes.each(function(d) { const node = cullTickLine ? this : this.querySelector("text"); if (node) node.style.display = (tickIndexMap.get(d) ?? 0) % intervalForCulling ? "none" : null; }); } else tickNodes.style("display", null); if (type === "x") { const clipPath = current.maxTickSize.x.clipPath ? clip.pathXAxisTickTexts : null; $el.svg.selectAll(`.${$AXIS.axisX} .tick text`).attr("clip-path", clipPath); } } }); } /** * Set axis tooltip * @private */ setAxisTooltip() { const $$ = this.owner; const { config: { axis_rotated: isRotated, axis_tooltip }, $el: { axis, axisTooltip } } = $$; const bgColor = axis_tooltip.backgroundColor ?? "black"; $$.generateTextBGColorFilter(bgColor, { x: -.15, y: -.2, width: 1.3, height: 1.3 }); [ "x", "y", "y2" ].forEach((v) => { if (isString(bgColor) || bgColor[v]) { axisTooltip[v] = axis[v]?.append("text").classed($AXIS[`axis${v.toUpperCase()}Tooltip`], true).attr("filter", $$.updateTextBGColor({ id: v }, bgColor)); if (isRotated) axisTooltip[v]?.attr(v === "x" ? "x" : "y", v === "y" ? "1.15em" : v === "x" ? "-0.3em" : "-0.4em").attr(`d${v === "x" ? "y" : "x"}`, v === "x" ? "0.4em" : "-1.3em").style("text-anchor", v === "x" ? "end" : null); else axisTooltip[v]?.attr(v === "x" ? "y" : "x", v === "x" ? "1.15em" : `${v === "y" ? "-" : ""}0.4em`).attr(`d${v === "x" ? "x" : "y"}`, v === "x" ? "-1em" : "0.3em").style("text-anchor", v === "y" ? "end" : null); } }); } }; //#endregion export { Axis_default as default };