billboard.js
Version:
Re-usable easy interface JavaScript chart library, based on D3 v4+
382 lines (381 loc) • 16.2 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, isObject, isString } from "../../module/util/type-checks.js";
import { capitalize, getRandom } from "../../module/util/object.js";
import { getBBox, getBoundingRect, getElementPos, getTranslation, setTextValue } from "../../module/util/dom.js";
import { $COMMON, $TEXT } from "../../config/classes.js";
import { KEY } from "../../module/Cache.js";
import { batchGetBBox, getRotateAnchor, getTextPos, meetsLabelThreshold, setRotatePos, updateTextBorder, updateTextImage, updateTextImagePos } from "./text.util.js";
import { select, selectAll } from "d3-selection";
//#region src/ChartInternal/internals/text.ts
/**
* Copyright (c) 2017 ~ present NAVER Corp.
* billboard.js project is licensed under the MIT license
*/
var text_default = {
opacityForText(d) {
const $$ = this;
return $$.isBarType(d) && !meetsLabelThreshold.call($$, Math.abs($$.getRatio("bar", d)), "bar") ? "0" : $$.hasDataLabel() ? null : "0";
},
/**
* Initializes the text
* @private
*/
initText() {
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) {
const $$ = this;
const classChartText = $$.getChartClass("Text");
const classTexts = $$.getClass("texts", "id");
const classFocus = $$.classFocus.bind($$);
$$.$el.main.select(`.${$TEXT.chartTexts}`).selectAll(`.${$TEXT.chartText}`).data($$.filterNullish(targets)).attr("class", (d) => `${classChartText(d)}${classFocus(d)}`.trim()).enter().append("g").style("opacity", "0").attr("class", classChartText).call($$.setCssRule(true, ` .${$TEXT.text}`, ["fill", "pointer-events:none"], $$.updateTextColor)).append("g").attr("class", classTexts);
},
/**
* Update text
* @private
*/
updateText() {
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) => {
let isEndAnchor = config[`axis_${axis?.getId(d.id)}_inverted`] ? d.value > 0 : d.value < 0;
if ($$.isCandlestickType(d)) isEndAnchor = !$$.getCandlestickData(d)?._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 = select(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, void 0, true);
});
updateTextImage.call($$);
},
updateTextColor(d) {
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)) {
if (!$$.getCandlestickData(d)?._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|function} option option object
* @returns {string|null}
* @private
*/
updateTextBGColor(d, option) {
const $$ = this;
const { $el: { defs } } = $$;
let color = "";
if (option) {
const id = isString(option) ? "" : $$.getTargetSelectorSuffix("id" in d ? d.id : d.data.id);
const filter = defs.select(["filter[id*='labels-bg", "']"].join(id));
if (filter.size()) color = `url(#${filter.attr("id")})`;
if (isFunction(option)) {
$$.generateTextBGColorFilter(option);
const defaultColor = $$.color(d);
const bgColor = option.bind($$.api)(defaultColor, d);
if (bgColor) filter.select("feFlood").attr("flood-color", bgColor);
else color = "";
}
}
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, withTransition) {
const $$ = this;
const { $T, axis, config, state: { hasTreemap } } = $$;
const t = getRandom(true);
const isRotated = config.axis_rotated;
const angle = config.data_labels.rotate;
const anchorString = getRotateAnchor(angle);
const rotateString = angle ? `rotate(${angle})` : "";
const text = $$.$el.text.style("fill", $$.getStylePropValue($$.updateTextColor)).attr("filter", (d) => $$.updateTextBGColor.bind($$)(d, config.data_labels_backgroundColors)).style("fill-opacity", forFlow ? 0 : $$.opacityForText.bind($$));
const dimensions = /* @__PURE__ */ new Map();
const elementsToMeasure = [];
text.each(function(d) {
const element = this;
dimensions.set(element, { rect: getBoundingRect(element) });
if (config.data_labels.centered && ($$.isBarType(d) || $$.isTreemapType(d))) elementsToMeasure.push(element);
});
if (elementsToMeasure.length > 0) batchGetBBox(elementsToMeasure).forEach((bbox, element) => {
const dimension = dimensions.get(element);
dimension && (dimension.bbox = bbox);
});
text.each(function(d, i) {
const labelDimension = dimensions.get(this);
const node = $T(hasTreemap && this.childElementCount ? this.parentNode : this, !!(withTransition && (this.getAttribute("x") || this.getAttribute("transform"))), t);
const isInverted = config[`axis_${axis?.getId(d.id)}_inverted`];
let pos = {
x: getX.call(this, d, i, labelDimension),
y: getY.call(this, d, i, labelDimension)
};
if (angle) {
pos = setRotatePos.bind($$)(d, pos, anchorString, isRotated, isInverted);
node.attr("text-anchor", anchorString);
}
updateTextImagePos.call($$, this, pos, labelDimension?.rect);
if (this.childElementCount || angle) node.attr("transform", `translate(${pos.x} ${pos.y}) ${rotateString}`);
else node.attr("x", pos.x).attr("y", pos.y);
config.data_labels.border && updateTextBorder.call($$, node.node(), pos, `${$TEXT.textBorderRect}-${i}`);
});
return true;
},
/**
* Gets the getBoundingClientRect value of the element
* @param {HTMLElement|d3.selection|Array} source Target element
* @param {string} className Class name
* @returns {object} value of element.getBoundingClientRect()
* @private
*/
getTextRect(source, className) {
const $$ = this;
let cacheKey;
let base;
let text;
if (Array.isArray(source)) cacheKey = `${KEY.textRect}-${source.join("_")}`;
else {
base = source.node?.() ?? source;
if (!/text/i.test(base.tagName)) base = base.querySelector("text");
text = base.textContent;
cacheKey = `${KEY.textRect}-${text.replace(/\W/g, "_")}`;
}
const rect = $$.cache.get(cacheKey) || [];
if (rect.length === 0) {
($$.$el.svg || $$.$el.chart.select("svg")).selectAll(`.${$COMMON.dummy}`).data(text ? [text] : source).enter().append("text").style("visibility", "hidden").style("font", base ? select(base).style("font") : null).classed(className || $COMMON.dummy, true).text((d) => d).each(function(v, i) {
rect[i] = getBoundingRect(this);
}).remove();
$$.cache.add(cacheKey, rect);
}
return rect.length > 1 ? rect : rect[0];
},
/**
* Gets the x or y coordinate of the text
* @param {object} indices Indices values
* @param {boolean} forX whether or not to x
* @returns {function} coordinates
* @private
*/
generateXYForText(indices, forX) {
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, labelDimension) {
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, labelDimension);
};
},
/**
* 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'
* @param {object} labelDimension Optional cached label dimensions
* @returns {number} Position value
* @private
*/
getCenteredTextPos(d, points, textElement, type, labelDimension) {
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 = labelDimension?.bbox || getBBox(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.y - 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
* @param {object} labelDimension Optional cached label dimensions
* @returns {number} x coordinate
* @private
*/
getXForText(points, d, textElement, labelDimension) {
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) {
if (points) xPos = points[2]?.[0] ?? xPos;
else return getElementPos(textElement, "x");
} 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;
xPos = points[2][1];
xPos += padding * ((isInverted ? value > 0 : value < 0) ? -1 : 1);
} else xPos = $$.hasType("bar") ? (points[2][0] + points[0][0]) / 2 : xPos;
if (isRotated || isTreemapType || isFunnelType) xPos += $$.getCenteredTextPos(d, points, textElement, "x", labelDimension);
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
* @param {object} labelDimension Optional cached label dimensions
* @returns {number} y coordinate
* @private
*/
getYForText(points, d, textElement, labelDimension) {
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 = labelDimension?.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) {
if (points) yPos = (points[2]?.[1] ?? points[0][1]) + rect.height / 2 - 3;
else return getElementPos(textElement, "y");
} 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 * .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", labelDimension);
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, $$, selector) {
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());
const calcHypo = (x, y) => Math.sqrt(x * x + y * y);
if (!textNode.node()) return;
const fontSize = parseInt(textNode.style("font-size"), 10);
const overlapStates = [];
filteredTextNodes.each(function() {
const coordinate = getTranslation(this);
const nodeForWidth = calcHypo(translate.e, translate.f) > calcHypo(coordinate.e, coordinate.f) ? textNode : select(this);
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)) < fontSize;
overlapStates.push({
node: this,
overlaps: overlapsX && overlapsY
});
});
overlapStates.forEach(({ node, overlaps }) => {
select(node).classed($TEXT.TextOverlapping, overlaps);
});
},
/**
* 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) {
$$.$el.arcs.selectAll(selector).each(function() {
selectAll([this, this.previousSibling]).classed($TEXT.TextOverlapping, false);
});
}
};
//#endregion
export { text_default as default };