Skip to main content
Module

x/billboardjs/ChartInternal/internals/text.ts

πŸ“Š Re-usable, easy interface JavaScript chart library based on D3.js
Go to Latest
File
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612
/** * 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 {KEY} from "../../module/Cache";import {$COMMON, $TEXT} from "../../config/classes";import {capitalize, getBoundingRect, getRandom, isFunction, isNumber, isObject, isString, getTranslation, setTextValue} from "../../module/util";import type {IDataRow, IArcData} from "../data/IData";import type {AxisType} from "../../../types/types";
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};}
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); },
/** * 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));
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} = $$; const classText = $$.getClass("text", "index");
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 => { // when value is negative or let isEndAnchor = d.value < 0;
if ($$.isCandlestickType(d)) { const data = $$.getCandlestickData(d);
isEndAnchor = !data?._isUp; }
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 = $$.dataLabelFormat(d.id)(value, d.id, i, 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) ? 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 * @returns {string|null} * @private */ updateTextBacgroundColor(d: IDataRow | IArcData): string | null { const $$ = this; const {$el, config} = $$; const backgroundColor = config.data_labels_backgroundColors; let color: string = "";
if (isString(backgroundColor) || isObject(backgroundColor)) { const id = isString(backgroundColor) ? "" : $$.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} = $$; 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", $$.updateTextBacgroundColor.bind($$)) .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(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 types = Object.keys(indices); const points = {}; const getter = forX ? $$.getXForText : $$.getYForText;
$$.hasType("radar") && types.push("radar");
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") || ($$.isRadarType(d) && "radar") || "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 * @returns {number} Position value * @private */ getCenteredTextPos(d, points, textElement): number { const $$ = this; const {config} = $$; const isRotated = config.axis_rotated;
if (config.data_labels.centered && $$.isBarType(d)) { const rect = getBoundingRect(textElement); const isPositive = d.value >= 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; } }
return 0; },
/** * Get data.labels.position value * @param {string} id Data id value * @param {string} type x | y * @returns {number} Position value * @private */ getTextPos(id, type): number { const pos = this.config.data_labels_position;
return (id in pos ? pos[id] : pos)[type] || 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, textElement): number { const $$ = this; const {config, state} = $$; const isRotated = config.axis_rotated; let xPos = points[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 (isRotated) { const padding = $$.isBarType(d) ? 4 : 6;
xPos = points[2][1] + padding * (d.value < 0 ? -1 : 1); } else { xPos = $$.hasType("bar") ? (points[2][0] + points[0][0]) / 2 : xPos; } }
// show labels regardless of the domain if value is null if (d.value === null) { if (xPos > state.width) { const {width} = getBoundingRect(textElement);
xPos = state.width - width; } else if (xPos < 0) { xPos = 4; } }
if (isRotated) { xPos += $$.getCenteredTextPos(d, points, textElement); }
return xPos + $$.getTextPos(d.id, "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 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 (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; } } }
// show labels regardless of the domain if value is null if (d.value === null && !isRotated) { const boxHeight = rect.height;
if (yPos < boxHeight) { yPos = boxHeight; } else if (yPos > state.height) { yPos = state.height - 4; } }
if (!isRotated) { yPos += $$.getCenteredTextPos(d, points, textElement); }
return yPos + $$.getTextPos(d.id, "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"): boolean { const $$ = this; const {config} = $$; const threshold = config[`${type}_label_threshold`] || 0;
return ratio >= threshold; }};