billboard.js
Version:
Re-usable easy interface JavaScript chart library, based on D3 v4+
433 lines (430 loc) • 14.3 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 { isCanvasBubbleType, getFontSize, isCanvasCandlestickType, isCanvasLineType, isCanvasScatterType } from './util.js';
import { isFunction, isNumber, isString, isObject } from '../module/util/type-checks.js';
import { tplProcess, parseShorthand } from '../module/util/object.js';
/**
* Copyright (c) 2017 ~ present NAVER Corp.
* billboard.js project is licensed under the MIT license
*/
// Canvas text has no DOM line box/getBBox. Use the common 1.2 line-height ratio so
// multiline data label decoration boxes stay close to SVG/CSS layout.
const LABEL_LINE_HEIGHT_RATIO = 1.2;
/**
* Get the data label value matching SVG label formatter input.
* @param {object} $$ ChartInternal instance
* @param {object} d Data row
* @returns {number|object|Array|null} Data label value
* @private
*/
function getLabelValue($$, d) {
let { value } = d;
if ($$.isBubbleZType?.(d)) {
value = $$.getBubbleZData(value, "z");
}
else if ($$.isCandlestickType?.(d)) {
const data = $$.getCandlestickData?.(d);
if (data) {
value = data.close;
}
}
return value;
}
/**
* Convert a data label value into drawable text.
* @param {object} $$ ChartInternal instance
* @param {object} d Data row
* @returns {string|null} Label text
* @private
*/
function getLabelText($$, d) {
const value = $$.dataLabelFormat(d.id)(getLabelValue($$, d), d.id, d.index, []);
return value === null || value === undefined ? null : String(value);
}
/**
* Get a stable key for data label caches.
* @param {object} d Data row
* @returns {string} Label key
* @private
*/
function getLabelRowKey(d) {
return `${d.id}:${d.index}`;
}
/**
* Build a matcher for SVG-like _expanded_ bar type focus.
* @param {object} $$ ChartInternal instance
* @param {Array} selectedData Focused data rows
* @param {function} typeFilter Canvas type filter
* @returns {function} Focus matcher
* @private
*/
function getExpandedFocusMatcher($$, selectedData, typeFilter) {
if (!selectedData?.length) {
return () => false;
}
if ($$.config.tooltip_grouped && selectedData.length > 1) {
const index = selectedData[0]?.index;
return d => index !== undefined && d.index === index && typeFilter($$, d);
}
const keys = new Set(selectedData
.filter(d => d && typeFilter($$, d))
.map(getLabelRowKey));
return d => keys.has(getLabelRowKey(d));
}
/**
* Get data label position offset.
* @param {object} $$ ChartInternal instance
* @param {object} d Data row
* @param {string} type Coordinate type
* @param {object} texts SVG-compatible text collection facade
* @returns {number} Offset value
* @private
*/
function getLabelPosition($$, d, type, texts) {
const position = $$.config.data_labels_position;
let value;
if (isFunction(position)) {
value = position.bind($$.api)(type, getLabelValue($$, d), d.id, d.index, texts);
}
else if (position) {
const targetPosition = d.id in position ? position[d.id] : position;
value = targetPosition?.[type];
}
return isNumber(value) ? value : 0;
}
/**
* Get image option for data label.
* @param {object} $$ ChartInternal instance
* @param {object} d Data row
* @returns {object|null} Image option
* @private
*/
function getLabelImageOption($$, d) {
const option = $$.config.data_labels?.image;
if (isFunction(option)) {
return option.call($$.api, getLabelValue($$, d), d.id, d.index) ?? null;
}
else if (option) {
const { url = "", width = 0, height = 0, pos } = option;
return { url, width, height, pos };
}
return null;
}
/**
* Get resolved image URL for a data label row.
* @param {object} option Image option
* @param {object} d Data row
* @returns {string} Resolved image URL
* @private
*/
function getLabelImageUrl(option, d) {
return tplProcess(option.url, {
ID: d.id
});
}
/**
* Get image and adjusted text position matching SVG label image placement.
* @param {object} $$ ChartInternal instance
* @param {object} option Image option
* @param {string} text Label text
* @param {number} x Text x coordinate
* @param {number} y Text y coordinate
* @param {object} d Data row
* @returns {object} Image and text positions
* @private
*/
function getLabelImagePosition($$, option, text, x, y, d) {
const { width = 0, height = 0, pos } = option;
const w = width / 2;
const h = height / 2;
const textHeight = getLabelDecorationBox($$.canvasEngine.ctx, text, x, y).h;
const fontSize = getFontSize($$.canvasEngine.ctx.font);
const imageX = x - w;
const imageY = y - h - ($$.isTreemapType?.(d) ? fontSize * 0.7 : textHeight / 2);
let textX = x;
let textY = y;
if ($$.config.axis_rotated) {
textX += w;
}
else {
textY += h;
}
return {
x: imageX + (pos?.x ?? 0),
y: imageY + (pos?.y ?? 0),
textX,
textY
};
}
/**
* Get canvas fill style for data label text.
* @param {object} $$ ChartInternal instance
* @param {object} d Data row
* @param {string} fallback Fallback color
* @returns {string} Canvas fill style
* @private
*/
function getLabelColor($$, d, fallback) {
const color = $$.updateTextColor?.(d);
return typeof color === "string" ?
color :
($$.isTreemapType?.(d) ? fallback : ($$.color?.(d) || fallback));
}
/**
* Get canvas fill style for data label background.
* @param {object} $$ ChartInternal instance
* @param {object} d Data row
* @returns {string|null} Canvas fill style
* @private
*/
function getLabelBackgroundColor($$, d) {
const option = $$.config.data_labels_backgroundColors;
if (!option) {
return null;
}
if (isString(option)) {
return option;
}
if (isFunction(option)) {
const defaultColor = $$.color?.(d);
const color = option.bind($$.api)(defaultColor, d);
return color === null || color === undefined ? null : String(color);
}
const color = option[d.id];
return color === null || color === undefined ? null : String(color);
}
/**
* Get normalized canvas data label border option.
* @param {object|boolean} border Border option
* @returns {object|null} Border option
* @private
*/
function getLabelBorderOption(border) {
if (!border) {
return null;
}
const option = isObject(border) ? border : {};
return {
padding: parseShorthand(option.padding ?? "3 5"),
radius: isNumber(option.radius) ? option.radius : 10,
stroke: option.stroke ?? "#000",
strokeWidth: isNumber(option.strokeWidth) ? option.strokeWidth : 1,
fill: option.fill ?? "none"
};
}
/**
* Measure the canvas rectangle needed behind a data label.
* @param {CanvasRenderingContext2D} ctx Canvas context
* @param {string} text Label text
* @param {number} x Label x coordinate
* @param {number} y Label y coordinate
* @param {object} padding Padding values
* @returns {object} Canvas rectangle
* @private
*/
function getLabelDecorationBox(ctx, text, x, y, padding = { top: 0, right: 0, bottom: 0, left: 0 }) {
const lines = text.split("\n");
const metrics = lines.map(line => ctx.measureText(line));
const width = Math.max(...metrics.map(metric => metric.width), 0);
const fontSize = getFontSize(ctx.font);
const lineHeight = fontSize * LABEL_LINE_HEIGHT_RATIO;
const fontBoundingHeight = metrics[0] ?
(metrics[0].fontBoundingBoxAscent || 0) + (metrics[0].fontBoundingBoxDescent || 0) :
0;
const baselineDescent = metrics[0] ?
metrics[0].fontBoundingBoxDescent || metrics[0].actualBoundingBoxDescent || 0 :
0;
const height = lines.length > 1 ? lineHeight * lines.length : Math.max(fontSize, fontBoundingHeight, metrics[0]?.actualBoundingBoxAscent + metrics[0]?.actualBoundingBoxDescent || 0);
let textX = x;
let textY = y;
if (ctx.textAlign === "center") {
textX -= width / 2;
}
else if (ctx.textAlign === "right" || ctx.textAlign === "end") {
textX -= width;
}
if (ctx.textBaseline === "middle") {
textY -= height / 2;
}
else if (ctx.textBaseline === "alphabetic") {
textY -= height - baselineDescent;
}
else if (ctx.textBaseline === "bottom" ||
ctx.textBaseline === "ideographic") {
textY -= height;
}
return {
x: textX - padding.left,
y: textY - padding.top,
w: width + padding.left + padding.right,
h: height + padding.top + padding.bottom
};
}
/**
* Draw data label background and border before drawing text.
* @param {object} $$ ChartInternal instance
* @param {CanvasPainter} painter Canvas painter
* @param {object} d Data row
* @param {string} text Label text
* @param {number} x Label x coordinate
* @param {number} y Label y coordinate
* @private
*/
function drawLabelDecorations($$, painter, d, text, x, y) {
const ctx = painter.context;
const backgroundColor = getLabelBackgroundColor($$, d);
const border = getLabelBorderOption($$.config.data_labels?.border);
const padding = border?.padding ?? { top: 0, right: 0, bottom: 0, left: 0 };
const box = getLabelDecorationBox(ctx, text, x, y, padding);
const angle = $$.config.data_labels.rotate;
painter.withState(canvas => {
if (angle) {
canvas.translate(x, y);
canvas.rotate(angle * Math.PI / 180);
box.x -= x;
box.y -= y;
}
if (backgroundColor) {
painter.fillRoundRect(box, border?.radius ?? 0, { fill: backgroundColor });
}
if (border) {
if (border.fill !== "none") {
painter.fillRoundRect(box, border.radius, { fill: border.fill });
}
painter.strokeRoundRect(box, border.radius, {
stroke: border.stroke,
lineWidth: border.strokeWidth
});
}
});
}
/**
* Get SVG-compatible text anchor for rotated data labels.
* @param {number} angle Rotation angle
* @returns {string} Anchor string
* @private
*/
function getLabelRotateAnchor(angle) {
let anchor = "middle";
if (angle > 0 && angle <= 170) {
anchor = "end";
}
else if (angle > 190 && angle <= 360) {
anchor = "start";
}
return anchor;
}
/**
* Map SVG text-anchor to canvas text alignment.
* @param {string} anchor SVG-compatible anchor
* @returns {string} Canvas text alignment
* @private
*/
function getCanvasTextAlign(anchor) {
return anchor === "start" ? "left" : (anchor === "end" ? "right" : "center");
}
/**
* Resolve rotated data label position using the same offset rules as SVG text.
* @param {object} $$ ChartInternal instance
* @param {object} d Data row
* @param {number} x Label x coordinate
* @param {number} y Label y coordinate
* @returns {object} Adjusted label position and alignment
* @private
*/
function getRotatedLabelPosition($$, d, x, y) {
const anchor = getLabelRotateAnchor($$.config.data_labels.rotate);
const isRotated = $$.config.axis_rotated;
const isInverted = $$.config[`axis_${$$.axis?.getId(d.id)}_inverted`];
const isCandlestickType = isCanvasCandlestickType($$, d);
const { value } = d;
const isNegative = (isNumber(value) && value < 0) || (isCandlestickType && !$$.getCandlestickData?.(d)?._isUp);
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,
textAlign: getCanvasTextAlign(anchor)
};
}
/**
* Resolve point-like data label anchor and canvas text alignment.
* @param {object} $$ ChartInternal instance
* @param {CanvasRenderingContext2D} ctx Canvas context
* @param {object} d Data row
* @param {number} x Point x coordinate
* @param {number} y Point y coordinate
* @returns {object} Label anchor
* @private
*/
function getPointLabelAnchor($$, ctx, d, x, y) {
if ($$.config.axis_rotated) {
x += 6;
ctx.textAlign = "left";
ctx.textBaseline = "middle";
}
else if (isCanvasBubbleType($$, d)) {
ctx.textAlign = "center";
ctx.textBaseline = "middle";
}
else {
const { config, state } = $$;
const isInverted = config[`axis_${$$.axis?.getId(d.id)}_inverted`];
const isNegative = d.value < 0 ||
(d.value === 0 && !state.hasPositiveValue && state.hasNegativeValue);
let baseY = 3;
if (isNumber(config.point_r) &&
config.point_r > 5 &&
(isCanvasLineType($$, d) || isCanvasScatterType($$, d))) {
baseY += config.point_r / 2.3;
}
ctx.textAlign = "center";
if (isInverted ? !isNegative : isNegative) {
y += baseY;
ctx.textBaseline = "top";
}
else {
y -= baseY * 2;
ctx.textBaseline = "bottom";
}
}
return { x, y };
}
export { drawLabelDecorations, getCanvasTextAlign, getExpandedFocusMatcher, getLabelBackgroundColor, getLabelBorderOption, getLabelColor, getLabelDecorationBox, getLabelImageOption, getLabelImagePosition, getLabelImageUrl, getLabelPosition, getLabelRotateAnchor, getLabelRowKey, getLabelText, getLabelValue, getPointLabelAnchor, getRotatedLabelPosition };