UNPKG

billboard.js

Version:

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

305 lines (254 loc) 9.38 kB
/** * Copyright (c) 2017 ~ present NAVER Corp. * billboard.js project is licensed under the MIT license */ import {select as d3Select} from "d3-selection"; import type {d3Selection} from "../../../types/types"; import {$ARC} from "../../config/classes"; import {window} from "../../module/browser"; import {isFunction, isObjectType, setTextValue} from "../../module/util"; import type {IArcData} from "../data/IData"; // 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): string | undefined { const $$ = this; return ["donut", "pie", "polar", "gauge"].find(type => $$.hasType(type) && !(type === "gauge" && excludeMultiGauge && $$.hasMultiArcGauge()) ); } /** * Label line configuration type */ type LabelLineConfig = { chartType: string | undefined, line: {show: boolean, distance: number}, text: {formatter: ((value: number, ratio: number, id: string) => string) | null} }; /** * 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(): LabelLineConfig { 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: number, ratio: number, id: string) => 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: ((value: number, ratio: number, id: string) => string) | null = 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: IArcData, lineDistance: number ): { startPoint: {x: number, y: number}, breakPoint: {x: number, y: number}, endPoint: {x: number, y: number}, isRight: boolean, midAngle: number } | null { 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(): boolean { 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: number): void { 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: number | null = null; arcs.selectAll(`.${$ARC.chartArc}`).each(function(d) { const g = d3Select(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}`) as d3Selection; if (line.empty()) { line = g.append("polyline") .attr("class", $ARC.arcLabelLine) as d3Selection; } $T(line, duration) .attr("points", points) .style("stroke", $$.color(d.data)) .style("opacity", isVisible ? null : "0"); } // --- Render text --- let labelLineText = g.select(`.${$ARC.arcLabelLineText}`) as d3Selection; if (labelLineText.empty()) { labelLineText = g.append("text") .attr("class", $ARC.arcLabelLineText) .style("pointer-events", "none") as d3Selection; } 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() as SVGTextElement; const tspanNodes = textNode?.querySelectorAll("tspan"); // Cache fontSize on first access to avoid repeated getComputedStyle calls if (cachedFontSize === null) { cachedFontSize = parseFloat(window.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};