billboard.js
Version:
Re-usable easy interface JavaScript chart library, based on D3 v4+
416 lines (352 loc) • 11.3 kB
text/typescript
/**
* 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 CLASS from "../../config/classes";
import {capitalize, getBoundingRect, getRandom, isNumber, isObject, isString, getTranslation} from "../../module/util";
import {AxisType} from "../../../types/types";
export default {
opacityForText(d): "1" | "0" {
const $$ = this;
return $$.isBarType(d) && !$$.meetsLabelThreshold(
Math.abs($$.getRatio("bar", d),), "bar"
) ? "0" : ($$.hasDataLabel ? "1" : "0");
},
/**
* Initializes the text
* @private
*/
initText(): void {
const {$el} = this;
$el.main.select(`.${CLASS.chart}`).append("g")
.attr("class", CLASS.chartTexts);
},
/**
* Update chartText
* @param {object} targets $$.data.targets
* @private
*/
updateTargetsForText(targets): void {
const $$ = this;
const classChartText = $$.classChartText.bind($$);
const classTexts = $$.classTexts.bind($$);
const classFocus = $$.classFocus.bind($$);
const mainTextUpdate = $$.$el.main.select(`.${CLASS.chartTexts}`).selectAll(`.${CLASS.chartText}`)
.data(targets)
.attr("class", d => classChartText(d) + classFocus(d));
const mainTextEnter = mainTextUpdate.enter().append("g")
.style("opacity", "0")
.attr("class", classChartText)
.style("pointer-events", "none");
mainTextEnter.append("g")
.attr("class", classTexts);
},
/**
* Update text
* @param {number} durationForExit Fade-out transition duration
* @private
*/
updateText(durationForExit): void {
const $$ = this;
const {config, $el} = $$;
const dataFn = $$.labelishData.bind($$);
const classText = $$.classText.bind($$);
$el.text = $el.main.selectAll(`.${CLASS.texts}`).selectAll(`.${CLASS.text}`)
.data(d => ($$.isRadarType(d) ? d.values : dataFn(d)));
$el.text.exit()
.transition()
.duration(durationForExit)
.style("fill-opacity", "0")
.remove();
$el.text = $el.text.enter()
.append("text")
.merge($$.$el.text)
.attr("class", classText)
.attr("text-anchor", d => (config.axis_rotated ? (d.value < 0 ? "end" : "start") : "middle"))
.style("fill", $$.updateTextColor.bind($$))
.style("fill-opacity", "0")
.text((d, i, j) => {
const value = $$.isBubbleZType(d) ? $$.getBubbleZData(d.value, "z") : d.value;
return $$.dataLabelFormat(d.id)(value, d.id, i, j);
});
},
updateTextColor(d): null | object | string {
const $$ = this;
const labelColors = $$.config.data_labels_colors;
let color;
if (isString(labelColors)) {
color = labelColors;
} else if (isObject(labelColors)) {
const {id} = d.data || d;
color = labelColors[id];
}
return color || (
$$.isArcType(d) && !$$.isRadarType(d) ? null : $$.color(d)
);
},
/**
* Redraw chartText
* @param {Function} x Positioning function for x
* @param {Function} y Positioning function for y
* @param {boolean} forFlow Weather is flow
* @param {boolean} withTransition transition is enabled
* @returns {Array}
* @private
*/
redrawText(x, y, forFlow?: boolean, withTransition?: boolean): boolean {
const $$ = this;
const t: any = getRandom();
const opacityForText = forFlow ? 0 : $$.opacityForText.bind($$);
$$.$el.text.each(function(d, i: number) {
const text = d3Select(this);
// do not apply transition for newly added text elements
(withTransition && text.attr("x") ? text.transition(t) : text)
.attr("x", x.bind(this)(d, i))
.attr("y", d => y.bind(this)(d, i))
.style("fill", $$.updateTextColor.bind($$))
.style("fill-opacity", opacityForText);
});
// 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") ||
($$.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;
let padding;
if (isRotated) {
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 : points[0][0];
}
// 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 {config, state} = $$;
const isRotated = config.axis_rotated;
const r = config.point_r;
const rect = getBoundingRect(textElement);
let baseY = 3;
let yPos;
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 (d.value < 0 || (d.value === 0 && !state.hasPositiveValue && state.hasNegativeValue)) {
yPos += rect.height + ($$.isBarType(d) ? -baseY : baseY);
} else {
let diff = -baseY * 2;
if ($$.isBarType(d)) {
diff = -baseY;
} else if ($$.isBubbleType(d)) {
diff = baseY;
}
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(CLASS.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(CLASS.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"): boolean {
const $$ = this;
const {config} = $$;
const threshold = config[`${type}_label_threshold`] || 0;
return ratio >= threshold;
}
};