UNPKG

billboard.js

Version:

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

380 lines (379 loc) • 12.6 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.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 };