UNPKG

billboard.js

Version:

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

433 lines (430 loc) 14.3 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 { 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 };