billboard.js
Version:
Re-usable easy interface JavaScript chart library, based on D3 v4+
217 lines (216 loc) • 7.7 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 { isFunction, isNumber } from "../../module/util/type-checks.js";
import { parseShorthand, toMap, tplProcess } from "../../module/util/object.js";
import { getBBox, getBoundingRect } from "../../module/util/dom.js";
import { $TEXT } from "../../config/classes.js";
import { select } from "d3-selection";
//#region src/ChartInternal/internals/text.util.ts
/**
* Copyright (c) 2017 ~ present NAVER Corp.
* billboard.js project is licensed under the MIT license
*/
/**
* Get text-anchor according text.labels.rotate angle
* @param {number} angle Angle value
* @returns {string} Anchor string value
* @private
*/
function getRotateAnchor(angle) {
let 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, pos, anchor, isRotated, isInverted) {
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 = 8;
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 += 16);
} else if (anchor === "middle") y -= doubleGap;
else if (anchor === "end") {
x -= gap;
isNegative && (y += 16);
}
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) {
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;
}
/**
* Update text border
* @param {SVGTextElement} text Text element
* @param {Coord} pos Position object
* @param {string} rectClass Class name
* @private
*/
function updateTextBorder(text, pos, rectClass) {
const { config, $T } = this;
const isRotated = config.axis_rotated;
const { border: { padding = "3 5", radius = 10, stroke = "#000", strokeWidth = 1, fill = "none" } } = config.data_labels;
const borderPadding = parseShorthand(padding);
const applyStyle = config.data_labels.border !== true;
const textRect = getBBox(text);
let borderRect = select(text.previousElementSibling);
if (borderRect.empty() || borderRect.node()?.tagName !== "rect" || !borderRect.attr("class")?.includes(rectClass)) {
borderRect = select(text.parentNode).insert("rect", () => text).attr("class", `${$TEXT.textBorderRect} ${rectClass}`).attr("width", textRect.width + (applyStyle ? borderPadding.left + borderPadding.right : 0)).attr("height", textRect.height + (applyStyle ? borderPadding.top + borderPadding.bottom : 0));
if (applyStyle) borderRect.style("fill", fill).style("stroke", stroke).style("stroke-width", `${strokeWidth}px`).attr("rx", radius).attr("ry", radius);
}
$T(borderRect).attr("x", pos.x - (applyStyle ? borderPadding.left : 0) - (isRotated ? 0 : textRect.width / 2)).attr("y", pos.y - (applyStyle ? borderPadding.top : 0) - textRect.height / 4 * 3.2);
}
/**
* Check if meets the ratio to show data label text
* @param {number} ratio ratio to meet
* @param {string} type chart type
* @returns {boolean}
* @private
*/
function meetsLabelThreshold(ratio = 0, type) {
const { config } = this;
return ratio >= (config[`${type}_label_threshold`] || 0);
}
/**
* Update text image
* @private
*/
function updateTextImage() {
const $$ = this;
const { $el: { text }, config } = $$;
if ($$.state.arcWidth ? $$.getArcLabelConfig("image") : config.data_labels.image) text.filter(function() {
const prev = this.previousElementSibling;
if (prev) return prev.tagName !== "image" || !prev.classList.contains($TEXT.textLabelImage);
return true;
}).each(function(d) {
const image = getDataLabelImgUrl.call($$, d);
if (!image) return;
const { url, width, height, pos } = image;
if (url) select(this.parentNode)?.insert("image", `${this.getAttribute("class")?.replace(/(?:^(.)|\s)/g, ".$1") ?? "text"}`).style("opacity", "0").attr("href", (d) => tplProcess(url, { ID: "id" in d ? d.id : d.data.id })).attr("class", $TEXT.textLabelImage).style("pointer-events", "none").attr("width", width).attr("height", height).attr("transform", pos ? `translate(${pos.x ?? 0} ${pos.y ?? 0})` : null);
});
}
/**
* Get image URL for data label
* @param {object} d Data object
* @returns {string|null} Image URL
* @private
*/
function getDataLabelImgUrl(d) {
const $$ = this;
const { config, state } = $$;
const image = state.arcWidth ? $$.getArcLabelConfig("image") : config.data_labels.image;
if (isFunction(image)) return image.call($$.api, d.value, d.id, d.index) ?? {
url: "",
width: 0,
height: 0,
pos: {
x: 0,
y: 0
}
};
else if (image) {
const { url = "", width = 0, height = 0, pos } = image;
return {
url,
width,
height,
pos
};
}
return null;
}
/**
* Update text image position
* @param {SVGTextElement} textNode Text element
* @param {object} pos Position object
* @param {number} pos.x X coordinate
* @param {number} pos.y Y coordinate
* @param {DOMRect|SVGRect} textRect Optional cached text dimensions
* @private
*/
function updateTextImagePos(textNode, pos, textRect) {
const $$ = this;
const { config, state: { arcWidth, hasTreemap } } = $$;
const isRotated = config.axis_rotated;
const image = select(textNode.previousElementSibling);
const isShown = (textNode) => {
return (arcWidth ? textNode.textContent : textNode.style.opacity !== "0" && textNode.style.fillOpacity !== "0") && this.previousElementSibling?.tagName !== "image";
};
if (!image.empty() && image.node()?.tagName === "image") {
const textDimension = textRect || getBoundingRect(textNode);
const w = +image.attr("width") / 2;
const h = +image.attr("height") / 2;
let x = pos.x - w;
let y = pos.y - h - textDimension.height / 2;
if (isRotated) pos.x += w;
else {
if (hasTreemap) {
x = -w;
y = -(h * 2 + textDimension.height);
}
if (!($$.hasType("pie") || $$.hasType("polar"))) pos.y += h;
}
$$.$T(image).style("opacity", isShown(textNode) ? null : "0").attr("x", x).attr("y", y);
}
}
/**
* Batch getBBox() calls to avoid layout thrashing
* Collects all bbox calculations in a single read phase
* @param {SVGTextElement[]} elements Array of text elements
* @returns {Map<SVGTextElement, DOMRect>} Map of element to bbox
* @private
*/
function batchGetBBox(elements) {
return toMap(elements, (element) => element, (element) => getBBox(element, true));
}
//#endregion
export { batchGetBBox, getRotateAnchor, getTextPos, meetsLabelThreshold, setRotatePos, updateTextBorder, updateTextImage, updateTextImagePos };