UNPKG

billboard.js

Version:

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

666 lines (575 loc) 18 kB
/** * Copyright (c) 2017 ~ present NAVER Corp. * billboard.js project is licensed under the MIT license */ import {select as d3Select, selectAll as d3SelectAll} from "d3-selection"; import type {AxisType} from "../../../types/types"; import {$COMMON, $TEXT} from "../../config/classes"; import {KEY} from "../../module/Cache"; import { capitalize, getBoundingRect, getRandom, getTranslation, isFunction, isNumber, isObject, isString, setTextValue } from "../../module/util"; import type {IArcData, IDataRow} from "../data/IData"; type Coord = {x: number, y: number}; type Anchor = "start" | "middle" | "end"; /** * Get text-anchor according text.labels.rotate angle * @param {number} angle Angle value * @returns {string} Anchor string value * @private */ function getRotateAnchor(angle: number): Anchor { let anchor: Anchor = "middle"; if (angle > 0 && angle <= 170) { anchor = "end"; } else if (angle > 190 && angle <= 360) { anchor = "start"; } return anchor; } /** * Set rotated position coordinate according text.labels.rotate angle * @param {object} d Data object * @param {object} pos Position object * @param {object} pos.x x coordinate * @param {object} pos.y y coordinate * @param {string} anchor string value * @param {boolean} isRotated If axis is rotated * @param {boolean} isInverted If axis is inverted * @returns {object} x, y coordinate * @private */ function setRotatePos( d: IDataRow, pos: Coord, anchor: Anchor, isRotated: boolean, isInverted: boolean ): Coord { const $$ = this; const {value} = d; const isCandlestickType = $$.isCandlestickType(d); const isNegative = (isNumber(value) && value < 0) || ( isCandlestickType && !$$.getCandlestickData(d)?._isUp ); let {x, y} = pos; const gap = 4; const doubleGap = gap * 2; if (isRotated) { if (anchor === "start") { x += isNegative ? 0 : doubleGap; y += gap; } else if (anchor === "middle") { x += doubleGap; y -= doubleGap; } else if (anchor === "end") { isNegative && (x -= doubleGap); y += gap; } } else { if (anchor === "start") { x += gap; isNegative && (y += doubleGap * 2); } else if (anchor === "middle") { y -= doubleGap; } else if (anchor === "end") { x -= gap; isNegative && (y += doubleGap * 2); } if (isInverted) { y += isNegative ? -17 : (isCandlestickType ? 13 : 7); } } return {x, y}; } /** * Get data.labels.position value * @param {object} d Data object * @param {string} type x | y * @returns {number} Position value * @private */ function getTextPos(d, type): number { const position = this.config.data_labels_position; const {id, index, value} = d; return ( isFunction(position) ? position.bind(this.api)(type, value, id, index, this.$el.text) : (id in position ? position[id] : position)[type] ) ?? 0; } export default { opacityForText(d): null | "0" { const $$ = this; return $$.isBarType(d) && !$$.meetsLabelThreshold( Math.abs($$.getRatio("bar", d)), "bar" ) ? "0" : ($$.hasDataLabel ? null : "0"); }, /** * Initializes the text * @private */ initText(): void { const {$el} = this; $el.main.select(`.${$COMMON.chart}`).append("g") .attr("class", $TEXT.chartTexts) .style("pointer-events", $el.funnel || $el.treemap ? "none" : null); }, /** * Update chartText * @param {object} targets $$.data.targets * @private */ updateTargetsForText(targets): void { const $$ = this; const classChartText = $$.getChartClass("Text"); const classTexts = $$.getClass("texts", "id"); const classFocus = $$.classFocus.bind($$); const mainTextUpdate = $$.$el.main.select(`.${$TEXT.chartTexts}`) .selectAll(`.${$TEXT.chartText}`) .data(targets) .attr("class", d => `${classChartText(d)}${classFocus(d)}`.trim()); const mainTextEnter = mainTextUpdate.enter().append("g") .style("opacity", "0") .attr("class", classChartText) .call( $$.setCssRule(true, ` .${$TEXT.text}`, ["fill", "pointer-events:none"], $$.updateTextColor) ); mainTextEnter.append("g") .attr("class", classTexts); }, /** * Update text * @private */ updateText(): void { const $$ = this; const {$el, $T, config, axis} = $$; const classText = $$.getClass("text", "index"); const labelsCentered = config.data_labels.centered; const text = $el.main.selectAll(`.${$TEXT.texts}`) .selectAll(`.${$TEXT.text}`) .data($$.labelishData.bind($$)); $T(text.exit()) .style("fill-opacity", "0") .remove(); $el.text = text.enter() .append("text") .merge(text) .attr("class", classText) .attr("text-anchor", d => { const isInverted = config[`axis_${axis?.getId(d.id)}_inverted`]; // when value is negative or let isEndAnchor = isInverted ? d.value > 0 : d.value < 0; if ($$.isCandlestickType(d)) { const data = $$.getCandlestickData(d); isEndAnchor = !data?._isUp; } else if ($$.isTreemapType(d)) { return labelsCentered ? "middle" : "start"; } return (config.axis_rotated ? (isEndAnchor ? "end" : "start") : "middle"); }) .style("fill", $$.getStylePropValue($$.updateTextColor)) .style("fill-opacity", "0") .each(function(d, i, texts) { const node = d3Select(this); let {value} = d; if ($$.isBubbleZType(d)) { value = $$.getBubbleZData(value, "z"); } else if ($$.isCandlestickType(d)) { const data = $$.getCandlestickData(d); if (data) { value = data.close; } } value = $$.isTreemapType(d) ? $$.treemapDataLabelFormat(d)(node) : $$.dataLabelFormat(d.id)(value, d.id, d.index, texts); if (isNumber(value)) { this.textContent = value; } else { setTextValue(node, value); } }); }, updateTextColor(d): null | object | string { const $$ = this; const {config} = $$; const labelColors = config.data_labels_colors; const defaultColor = ($$.isArcType(d) && !$$.isRadarType(d)) || $$.isFunnelType(d) || $$.isTreemapType(d) ? null : $$.color(d); let color; if (isString(labelColors)) { color = labelColors; } else if (isObject(labelColors)) { const {id} = d.data || d; color = labelColors[id]; } else if (isFunction(labelColors)) { color = labelColors.bind($$.api)(defaultColor, d); } if ($$.isCandlestickType(d) && !isFunction(labelColors)) { const value = $$.getCandlestickData(d); if (!value?._isUp) { const downColor = config.candlestick_color_down; color = isObject(downColor) ? downColor[d.id] : downColor; } } return color || defaultColor; }, /** * Update data label text background color * @param {object} d Data object * @param {object|string} option option object * @returns {string|null} * @private */ updateTextBGColor(d: IDataRow | IArcData, option): string | null { const $$ = this; const {$el} = $$; let color: string = ""; if (isString(option) || isObject(option)) { const id = isString(option) ? "" : $$.getTargetSelectorSuffix("id" in d ? d.id : d.data.id); const filter = $el.defs.select(["filter[id*='labels-bg", "']"].join(id)); if (filter.size()) { color = `url(#${filter.attr("id")})`; } } return color || null; }, /** * Redraw chartText * @param {Function} getX Positioning function for x * @param {Function} getY Positioning function for y * @param {boolean} forFlow Weather is flow * @param {boolean} withTransition transition is enabled * @returns {Array} * @private */ redrawText(getX, getY, forFlow?: boolean, withTransition?: boolean): true { const $$ = this; const {$T, axis, config, state: {hasTreemap}} = $$; const t = <string>getRandom(true); const isRotated = config.axis_rotated; const angle = config.data_labels.rotate; const anchorString = getRotateAnchor(angle); const rotateString = angle ? `rotate(${angle})` : ""; $$.$el.text .style("fill", $$.getStylePropValue($$.updateTextColor)) .attr("filter", d => $$.updateTextBGColor.bind($$)(d, config.data_labels_backgroundColors)) .style("fill-opacity", forFlow ? 0 : $$.opacityForText.bind($$)) .each(function(d: IDataRow, i: number) { // do not apply transition for newly added text elements const node = $T(hasTreemap && this.childElementCount ? this.parentNode : this, !!(withTransition && this.getAttribute("x")), t); const isInverted = config[`axis_${axis?.getId(d.id)}_inverted`]; let pos = { x: getX.bind(this)(d, i), y: getY.bind(this)(d, i) }; if (angle) { pos = setRotatePos.bind($$)(d, pos, anchorString, isRotated, isInverted); node.attr("text-anchor", anchorString); } // when is multiline if (this.childElementCount || angle) { node.attr("transform", `translate(${pos.x} ${pos.y}) ${rotateString}`); } else { node.attr("x", pos.x).attr("y", pos.y); } }); // need to return 'true' as of being pushed to the redraw list // ref: getRedrawList() return true; }, /** * Gets the getBoundingClientRect value of the element * @param {HTMLElement|d3.selection} element Target element * @param {string} className Class name * @returns {object} value of element.getBoundingClientRect() * @private */ getTextRect(element, className: string): object { const $$ = this; let base = element.node ? element.node() : element; if (!/text/i.test(base.tagName)) { base = base.querySelector("text"); } const text = base.textContent; const cacheKey = `${KEY.textRect}-${text.replace(/\W/g, "_")}`; let rect = $$.cache.get(cacheKey); if (!rect) { $$.$el.svg.append("text") .style("visibility", "hidden") .style("font", d3Select(base).style("font")) .classed(className, true) .text(text) .call(v => { rect = getBoundingRect(v.node()); }) .remove(); $$.cache.add(cacheKey, rect); } return rect; }, /** * Gets the x or y coordinate of the text * @param {object} indices Indices values * @param {boolean} forX whether or not to x * @returns {number} coordinates * @private */ generateXYForText(indices, forX?: boolean): (d, i) => number { const $$ = this; const {state: {hasRadar, hasFunnel, hasTreemap}} = $$; const types = Object.keys(indices); const points = {}; const getter = forX ? $$.getXForText : $$.getYForText; hasFunnel && types.push("funnel"); hasRadar && types.push("radar"); hasTreemap && types.push("treemap"); types.forEach(v => { points[v] = $$[`generateGet${capitalize(v)}Points`](indices[v], false); }); return function(d, i) { const type = ($$.isAreaType(d) && "area") || ($$.isBarType(d) && "bar") || ($$.isCandlestickType(d) && "candlestick") || ($$.isFunnelType(d) && "funnel") || ($$.isRadarType(d) && "radar") || ($$.isTreemapType(d) && "treemap") || "line"; return getter.call($$, points[type](d, i), d, this); }; }, /** * Get centerized text position for bar type data.label.text * @param {object} d Data object * @param {Array} points Data points position * @param {HTMLElement} textElement Data label text element * @param {string} type 'x' or 'y' * @returns {number} Position value * @private */ getCenteredTextPos(d, points, textElement, type: "x" | "y"): number { const $$ = this; const {config} = $$; const isRotated = config.axis_rotated; const isBarType = $$.isBarType(d); const isTreemapType = $$.isTreemapType(d); if (config.data_labels.centered && (isBarType || isTreemapType)) { const rect = getBoundingRect(textElement); if (isBarType) { const isPositive = $$.getRangedData(d, null, "bar") >= 0; if (isRotated) { const w = ( isPositive ? points[1][1] - points[0][1] : points[0][1] - points[1][1] ) / 2 + (rect.width / 2); return isPositive ? -w - 3 : w + 2; } else { const h = ( isPositive ? points[0][1] - points[1][1] : points[1][1] - points[0][1] ) / 2 + (rect.height / 2); return isPositive ? h : -h - 2; } } else if (isTreemapType) { return type === "x" ? (points[1][0] - points[0][0]) / 2 : (points[1][1] - points[0][1]) / 2 + (rect.height / 2); } } return 0; }, /** * Gets the x coordinate of the text * @param {object} points Data points position * @param {object} d Data object * @param {HTMLElement} textElement Data label text element * @returns {number} x coordinate * @private */ getXForText(points, d: IDataRow, textElement): number { const $$ = this; const {config} = $$; const isRotated = config.axis_rotated; const isFunnelType = $$.isFunnelType(d); const isTreemapType = $$.isTreemapType(d); let xPos = points ? points[0][0] : 0; if ($$.isCandlestickType(d)) { if (isRotated) { xPos = $$.getCandlestickData(d)?._isUp ? points[2][2] + 4 : points[2][1] - 4; } else { xPos += (points[1][0] - xPos) / 2; } } else if (isFunnelType) { xPos += $$.state.current.width / 2; } else if (isTreemapType) { xPos += config.data_labels.centered ? 0 : 5; } else { if (isRotated) { const isInverted = config[`axis_${$$.axis.getId(d.id)}_inverted`]; const padding = $$.isBarType(d) ? 4 : 6; const value = d.value as number; xPos = points[2][1]; if (isInverted) { xPos -= padding * (value > 0 ? 1 : -1); } else { xPos += padding * (value < 0 ? -1 : 1); } } else { xPos = $$.hasType("bar") ? (points[2][0] + points[0][0]) / 2 : xPos; } } if (isRotated || isTreemapType) { xPos += $$.getCenteredTextPos(d, points, textElement, "x"); } return xPos + getTextPos.call(this, d, "x"); }, /** * Gets the y coordinate of the text * @param {object} points Data points position * @param {object} d Data object * @param {HTMLElement} textElement Data label text element * @returns {number} y coordinate * @private */ getYForText(points, d, textElement): number { const $$ = this; const {axis, config, state} = $$; const isRotated = config.axis_rotated; const isInverted = config[`axis_${axis?.getId(d.id)}_inverted`]; const isBarType = $$.isBarType(d); const isFunnelType = $$.isFunnelType(d); const isTreemapType = $$.isTreemapType(d); const r = config.point_r; const rect = getBoundingRect(textElement); let {value} = d; let baseY = 3; let yPos; if ($$.isCandlestickType(d)) { value = $$.getCandlestickData(d); if (isRotated) { yPos = points[0][0]; yPos += ((points[1][0] - yPos) / 2) + baseY; } else { yPos = value && value._isUp ? points[2][2] - baseY : points[2][1] + (baseY * 4); if (isInverted) { yPos += 15 * (value._isUp ? 1 : -1); } } } else if (isFunnelType) { yPos = points ? points[0][1] + ((points[1][1] - points[0][1]) / 2) + rect.height / 2 - 3 : 0; } else if (isTreemapType) { yPos = points[0][1] + (config.data_labels.centered ? 0 : rect.height + 5); } else { if (isRotated) { yPos = (points[0][0] + points[2][0] + rect.height * 0.6) / 2; } else { yPos = points[2][1]; if (isNumber(r) && r > 5 && ($$.isLineType(d) || $$.isScatterType(d))) { baseY += config.point_r / 2.3; } if ( value < 0 || (value === 0 && !state.hasPositiveValue && state.hasNegativeValue) ) { yPos += isInverted ? (isBarType ? -3 : -5) : ( rect.height + (isBarType ? -baseY : baseY) ); } else { let diff = -baseY * 2; if (isBarType) { diff = -baseY; } else if ($$.isBubbleType(d)) { diff = baseY; } if (isInverted) { diff = isBarType ? 10 : 15; } yPos += diff; } } } if (!isRotated || isTreemapType) { yPos += $$.getCenteredTextPos(d, points, textElement, "y"); } return yPos + getTextPos.call(this, d, "y"); }, /** * Calculate if two or more text nodes are overlapping * Mark overlapping text nodes with "text-overlapping" class * @param {string} id Axis id * @param {ChartInternal} $$ ChartInternal context * @param {string} selector Selector string * @private */ markOverlapped(id: AxisType, $$, selector: string): void { const textNodes = $$.$el.arcs.selectAll(selector); const filteredTextNodes = textNodes.filter(node => node.data.id !== id); const textNode = textNodes.filter(node => node.data.id === id); const translate = getTranslation(textNode.node()); // Calculates the length of the hypotenuse const calcHypo = (x, y) => Math.sqrt(Math.pow(x, 2) + Math.pow(y, 2)); textNode.node() && filteredTextNodes.each(function() { const coordinate = getTranslation(this); const filteredTextNode = d3Select(this); const nodeForWidth = calcHypo(translate.e, translate.f) > calcHypo(coordinate.e, coordinate.f) ? textNode : filteredTextNode; const overlapsX = Math.ceil(Math.abs(translate.e - coordinate.e)) < Math.ceil(nodeForWidth.node().getComputedTextLength()); const overlapsY = Math.ceil(Math.abs(translate.f - coordinate.f)) < parseInt(textNode.style("font-size"), 10); filteredTextNode.classed($TEXT.TextOverlapping, overlapsX && overlapsY); }); }, /** * Calculate if two or more text nodes are overlapping * Remove "text-overlapping" class on selected text nodes * @param {ChartInternal} $$ ChartInternal context * @param {string} selector Selector string * @private */ undoMarkOverlapped($$, selector): void { $$.$el.arcs.selectAll(selector) .each(function() { d3SelectAll([this, this.previousSibling]) .classed($TEXT.TextOverlapping, false); }); }, /** * Check if meets the ratio to show data label text * @param {number} ratio ratio to meet * @param {string} type chart type * @returns {boolean} * @private */ meetsLabelThreshold(ratio: number = 0, type: "bar" | "donut" | "gauge" | "pie" | "polar" | "treemap"): boolean { const $$ = this; const {config} = $$; const threshold = config[`${type}_label_threshold`] || 0; return ratio >= threshold; } };