UNPKG

billboard.js

Version:

Re-usable easy interface JavaScript chart library, based on D3 v4+

245 lines (242 loc) 9.99 kB
/*! * 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 };