billboard.js
Version:
Re-usable easy interface JavaScript chart library, based on D3 v4+
245 lines (242 loc) • 9.99 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.0.1
*/
import { select } from 'd3-selection';
import { $ARC } from '../../config/classes.js';
import { window as win } from '../../module/browser.js';
import { setTextValue } from '../../module/util/dom.js';
import { isObjectType, isFunction } from '../../module/util/type-checks.js';
/**
* Copyright (c) 2017 ~ present NAVER Corp.
* billboard.js project is licensed under the MIT license
*/
// Arc label line positioning constants (used in multiple places or non-obvious values)
const BREAK_POINT_OFFSET = 15; // Offset from arc edge to break point
const DEFAULT_LINE_DISTANCE = 20; // Default horizontal line distance
const TEXT_VERTICAL_OFFSET = 0.35; // Text vertical alignment offset (shared with arc.ts)
/**
* Get the first matching arc chart type
* @this {object} ChartInternal context
* @param {boolean} excludeMultiGauge Whether to exclude multi gauge type
* @returns {string|undefined} Chart type or undefined
* @private
*/
function getArcType(excludeMultiGauge = false) {
const $$ = this;
return ["donut", "pie", "polar", "gauge"].find(type => $$.hasType(type) && !(type === "gauge" && excludeMultiGauge && $$.hasMultiArcGauge()));
}
/**
* Get label line configuration (line and text)
* @this {object} ChartInternal context
* @returns {LabelLineConfig} Configuration with chartType, line (show, distance) and text (formatter)
* @private
*/
function getConfig() {
const $$ = this;
const { config } = $$;
const chartType = getArcType.call($$, true);
const lineConfig = chartType && config[`${chartType}_label_line`];
const isValidConfig = isObjectType(lineConfig);
// Default formatter: returns id
const defaultFormatter = (value, ratio, id) => id;
// Line configuration
const line = {
show: lineConfig === true || (isValidConfig && lineConfig?.show !== false),
distance: (isValidConfig && lineConfig?.distance) || DEFAULT_LINE_DISTANCE
};
// When lineConfig is boolean true, use all defaults
if (lineConfig === true) {
return {
chartType,
line,
text: { formatter: defaultFormatter }
};
}
// When no valid config, return default with show: false
if (!isValidConfig) {
return {
chartType,
line: { show: false, distance: DEFAULT_LINE_DISTANCE },
text: { formatter: null }
};
}
// Determine formatter based on text option
// - text is function: use custom formatter
// - text is true or undefined (not set): use default formatter (show id)
// - text is false: use label.format by returning null
let formatter = defaultFormatter;
if (isFunction(lineConfig.text)) {
formatter = lineConfig.text;
}
else if (lineConfig.text === false) {
formatter = null;
}
return {
chartType,
line,
text: { formatter }
};
}
/**
* Calculate label with line positions for arc data
* @this {object} ChartInternal context
* @param {object} d Data object
* @param {number} lineDistance Horizontal line distance (from getConfig)
* @returns {object|null} Object containing startPoint, breakPoint, endPoint, isRight, and midAngle
* @private
*/
function getLinePosition(d, lineDistance) {
const $$ = this;
const { state } = $$;
const updated = $$.updateAngle(d);
if (!updated) {
return null;
}
let { outerRadius } = $$.getRadius(d);
let arcOuterRadius = outerRadius;
if ($$.hasType("polar")) {
// For polar, arc radius is proportional to data value
arcOuterRadius = $$.getPolarOuterRadius(d, outerRadius);
// But labels should be positioned outside the full chart radius (levels)
outerRadius = state.radius;
}
let midAngle = (updated.startAngle + updated.endAngle) / 2;
// Check if this is a single data item (full circle arc)
// When arc spans the entire circle, position label on the right side to avoid legend overlap
const isFullCircleArc = Math.abs((updated.endAngle - updated.startAngle) - (2 * Math.PI)) < 0.01;
if (isFullCircleArc) {
// Position at 3 o'clock (π/2 from top) for single data item
midAngle = Math.PI / 2;
}
// Pre-calculate trigonometric values
const sinAngle = Math.sin(midAngle);
const cosAngle = -Math.cos(midAngle);
// Start point: at the arc edge (for polar, this is the data-proportional radius)
const startPoint = {
x: sinAngle * arcOuterRadius,
y: cosAngle * arcOuterRadius
};
// Break point: extends outward from the full chart radius (outside levels for polar)
const breakRadius = outerRadius + BREAK_POINT_OFFSET;
const breakPoint = {
x: sinAngle * breakRadius,
y: cosAngle * breakRadius
};
// Determine if label is on the right side of the chart
// Use pre-calculated sinAngle to account for startingAngle offset
const isRight = sinAngle >= 0;
// End point: extends horizontally from break point
const endPoint = {
x: breakPoint.x + (lineDistance * (isRight ? 1 : -1)),
y: breakPoint.y
};
return { startPoint, breakPoint, endPoint, isRight, midAngle };
}
/**
* Check if label with line type is enabled for arc charts
* @this {object} ChartInternal context
* @returns {boolean} Whether label with lines are enabled
* @private
*/
function isLabelWithLine() {
return getConfig.call(this).line.show;
}
/**
* Render connector lines and text for label with lines
* @this {object} ChartInternal context
* @param {number} duration Transition duration
* @private
*/
function redrawArcLabelLines(duration) {
const $$ = this;
const { $el: { arcs }, $T } = $$;
// Get config once and reuse (avoid N+1 calls)
const { line: lineConfig, text: textConfig } = getConfig.call($$);
const lineDistance = lineConfig.distance;
// Cache fontSize from first text element to avoid repeated getComputedStyle calls
let cachedFontSize = null;
arcs.selectAll(`.${$ARC.chartArc}`).each(function (d) {
const g = select(this);
const linePos = getLinePosition.call($$, d, lineDistance);
// Use cached values from textForArcLabel if available, otherwise calculate
const { ratio, meetsThreshold, updated } = d._cache ?? {};
// Handle invalid data
if (!updated || !linePos) {
return;
}
const isVisible = $$.isTargetToShow(d.data.id) && meetsThreshold;
// --- Render connector line (polyline) ---
const { startPoint, breakPoint, endPoint, isRight } = linePos;
const points = `${startPoint.x},${startPoint.y} ${breakPoint.x},${breakPoint.y} ${endPoint.x},${endPoint.y}`;
if (lineConfig.show) {
let line = g.select(`.${$ARC.arcLabelLine}`);
if (line.empty()) {
line = g.append("polyline")
.attr("class", $ARC.arcLabelLine);
}
$T(line, duration)
.attr("points", points)
.style("stroke", $$.color(d.data))
.style("opacity", isVisible ? null : "0");
}
// --- Render text ---
let labelLineText = g.select(`.${$ARC.arcLabelLineText}`);
if (labelLineText.empty()) {
labelLineText = g.append("text")
.attr("class", $ARC.arcLabelLineText)
.style("pointer-events", "none");
}
if (isVisible) {
const { value } = updated;
const { id } = d.data;
// Get formatted text
const text = (textConfig.formatter ??
$$.getArcLabelConfig("format") ??
$$.defaultArcValueFormat)(value, ratio, id).toString();
setTextValue(labelLineText, text, [-1, 1], false);
// Position the text at the end of the connector line (outside)
const pos = {
x: endPoint.x + (5 * (isRight ? 1 : -1)), // 5: label offset from endpoint
y: endPoint.y
};
// Configure text alignment based on position
labelLineText.style("text-anchor", isRight ? "start" : "end");
// Handle text vertical alignment by adjusting translate y position
const textNode = labelLineText.node();
const tspanNodes = textNode?.querySelectorAll("tspan");
// Cache fontSize on first access to avoid repeated getComputedStyle calls
if (cachedFontSize === null) {
cachedFontSize = parseFloat(win.getComputedStyle(textNode).fontSize) || 12;
}
if (tspanNodes && tspanNodes.length > 1) {
// Multiline: adjust for vertical centering
// With setTextValue(text, [-1, 1], false):
// - 1st line at -1em, 2nd at 0em, 3rd at 1em, 4th at 2em, ...
// - Center of text block = (-1 + (lineCount - 2)) / 2 = (lineCount - 3) / 2
// - To center at y=0, shift by negative of center
const lineCount = tspanNodes.length;
const centerOffset = (lineCount - 3) / 2;
pos.y += (-centerOffset + TEXT_VERTICAL_OFFSET) * cachedFontSize;
}
else {
// Single line: apply base vertical offset
pos.y += TEXT_VERTICAL_OFFSET * cachedFontSize;
}
$T(labelLineText, duration)
.attr("transform", `translate(${pos.x},${pos.y})`)
.style("opacity", null)
.style("fill", $$.updateTextColor.bind($$)(d));
}
else {
$T(labelLineText, duration)
.style("opacity", "0");
}
});
}
export { isLabelWithLine, redrawArcLabelLines };