billboard.js
Version:
Re-usable easy interface JavaScript chart library, based on D3 v4+
380 lines (379 loc) • 12.6 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.1.1
*/
import { isFunction, isNumber, isObject, isString } from "../module/util/type-checks.js";
import { parseShorthand, tplProcess } from "../module/util/object.js";
import { getFontSize, isCanvasBubbleType, isCanvasCandlestickType, isCanvasLineType, isCanvasScatterType } from "./util.js";
//#region src/canvas/labels.ts
/**
* Copyright (c) 2017 ~ present NAVER Corp.
* billboard.js project is licensed under the MIT license
*/
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 === void 0 ? 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 !== void 0 && 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) value = (d.id in position ? position[d.id] : position)?.[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 * .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 === void 0 ? null : String(color);
}
const color = option[d.id];
return color === null || color === void 0 ? 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 box = getLabelDecorationBox(ctx, text, x, y, border?.padding ?? {
top: 0,
right: 0,
bottom: 0,
left: 0
});
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 = 8;
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 += 16);
} else if (anchor === "middle") y -= doubleGap;
else if (anchor === "end") {
x -= gap;
isNegative && (y += 16);
}
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
};
}
//#endregion
export { drawLabelDecorations, getCanvasTextAlign, getExpandedFocusMatcher, getLabelBackgroundColor, getLabelBorderOption, getLabelColor, getLabelDecorationBox, getLabelImageOption, getLabelImagePosition, getLabelImageUrl, getLabelPosition, getLabelRotateAnchor, getLabelRowKey, getLabelText, getLabelValue, getPointLabelAnchor, getRotatedLabelPosition };