UNPKG

billboard.js

Version:

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

1,373 lines (1,372 loc) 97.7 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 { select } from 'd3-selection'; import CanvasAxisRenderer from '../../canvas/CanvasAxisRenderer.js'; import CanvasEngine from '../../canvas/CanvasEngine.js'; import CanvasRenderer from '../../canvas/CanvasRenderer.js'; import CanvasTheme from '../../canvas/CanvasTheme.js'; import { $CANVAS } from '../../canvas/classes.js'; import HitDetector from '../../canvas/HitDetector.js'; import { hasCanvasDrawableValue, isCanvasTargetSupported, isCanvasPointType, isCanvasBarType, isCanvasCandlestickType, isCanvasTreemapType, isCanvasScatterType, isCanvasBubbleType } from '../../canvas/util.js'; import ChartInternal from '../../ChartInternal/ChartInternal.js'; import { getRenderDataPoint } from '../../ChartInternal/shape/core/geometry.js'; import { $LEGEND, $FOCUS } from '../classes.js'; import { window as win } from '../../module/browser.js'; import { KEY } from '../../module/Cache.js'; import { extend, callFn, tplProcess, parseDate } from '../../module/util/object.js'; import { getBoundingRect } from '../../module/util/dom.js'; import { isFunction, isBoolean } from '../../module/util/type-checks.js'; import { sanitize } from '../../module/sanitize.js'; /** * Copyright (c) 2017 ~ present NAVER Corp. * billboard.js project is licensed under the MIT license */ const CANVAS_SELECTABLE_TYPE_FILTERS = [ isCanvasPointType, isCanvasBarType, isCanvasCandlestickType, isCanvasTreemapType ]; const CANVAS_LEGEND_TOUCH_TAP_THRESHOLD = 10; const CANVAS_LEGEND_TOUCH_CLICK_TIMEOUT = 750; const CANVAS_SUBCHART_HANDLE_SIZE = 6; const CANVAS_SUBCHART_CLICK_TOLERANCE = 2; const CANVAS_FLOW_ANIMATION_MAX_VALUES = 100000; const CANVAS_HTML_ATTR_ESCAPE = { "&": "&amp;", "<": "&lt;", ">": "&gt;", "\"": "&quot;", "'": "&#39;" }; const canvasSelectionDragRows = new WeakMap(); /** * Emit canvas v1 unsupported option warning. * @param {string} message Warning message * @private */ function warn(message) { win.console?.warn?.(`[billboard.js] ${message}`); } /** * Get formatted legend text. * @param {object} $$ ChartInternal instance * @param {string} id Data id * @returns {string} Legend text * @private */ function getLegendText($$, id) { const { config } = $$; const text = config.data_names[id] ?? id; return isFunction(config.legend_format) ? config.legend_format(text, id !== text ? id : undefined) : text; } /** * Escape a value for safe HTML attribute interpolation. * @param {string} value Attribute value * @returns {string} Escaped value * @private */ function escapeHtmlAttr(value) { return `${value}`.replace(/[&<>"']/g, char => CANVAS_HTML_ATTR_ESCAPE[char]); } /** * Get the point pattern assigned to a legend item. * @param {object} $$ ChartInternal instance * @param {string} id Data id * @returns {string} Point pattern * @private */ function getCanvasLegendPointPattern($$, id) { const { config } = $$; const targetIds = $$.mapToIds?.($$.data.targets) || []; const pattern = $$.getValidPointPattern?.() || (Array.isArray(config.point_pattern) && config.point_pattern.length ? config.point_pattern : [config.point_type]); const index = Math.max(0, targetIds.indexOf(id)); return pattern[index % pattern.length] || "circle"; } /** * Build inline SVG markup for a canvas HTML legend point tile. * @param {object} $$ ChartInternal instance * @param {string} id Data id * @returns {string} SVG markup * @private */ function getCanvasLegendPointIcon($$, id) { const color = escapeHtmlAttr($$.color(id)); const pattern = getCanvasLegendPointPattern($$, id); const svgAttrs = "viewBox=\"0 0 8 8\" aria-hidden=\"true\" focusable=\"false\" " + "style=\"display:block;width:100%;height:100%;overflow:visible;pointer-events:none\""; const paintAttrs = `fill="${color}" stroke="${color}"`; if (/^rect(angle)?$/i.test(pattern)) { return `<svg ${svgAttrs}><rect x="1" y="1" width="6" height="6" ${paintAttrs}></rect></svg>`; } if (/^circle$/i.test(pattern)) { return `<svg ${svgAttrs}><circle cx="4" cy="4" r="3" ${paintAttrs}></circle></svg>`; } return `<svg ${svgAttrs}><g ${paintAttrs}>${sanitize(pattern)}</g></svg>`; } /** * Update legend item lookup cache for canvas HTML legends. * @param {object} $$ ChartInternal instance * @param {object} item Legend item selection * @private */ function updateCanvasLegendItemMap($$, item) { const itemMap = new Map(); item.each(function (id) { itemMap.set(id, this); }); $$.cache.add(KEY.legendItemMap, itemMap); } /** * Apply shared canvas HTML legend classes and state. * @param {object} $$ ChartInternal instance * @param {object} item Legend item selection * @private */ function setCanvasHtmlLegendItem($$, item) { const { config } = $$; item .attr("class", function (id) { const current = select(this).attr("class") || ""; const next = `${current} ${$$.generateClass($LEGEND.legendItem, id)}`; return Array.from(new Set(next.trim().split(/\s+/).filter(Boolean))).join(" "); }) .style("visibility", id => ($$.isLegendToShow(id) ? null : "hidden")) .classed($LEGEND.legendItemHidden, id => !$$.isTargetToShow(id)) .classed($CANVAS.legendItemInteractive, !!config.interaction_enabled); updateCanvasLegendItemMap($$, item); } /** * Apply SVG-compatible legend focus styling to canvas HTML legend items. * @param {object} $$ ChartInternal instance * @param {string} id Focused data id * @private */ function setCanvasHtmlLegendFocus($$, id) { const { legend } = $$.$el; const targetIds = $$.mapToTargetIds?.([id]) || [id]; legend?.selectAll(`.${$LEGEND.legendItem}`) .classed($FOCUS.legendItemFocused, (d) => targetIds.indexOf(d) >= 0) .style("opacity", function (d) { return targetIds.indexOf(d) >= 0 ? null : $$.opacityForUnfocusedLegend.call($$, select(this)); }); } /** * Revert canvas HTML legend focus styling. * @param {object} $$ ChartInternal instance * @private */ function revertCanvasHtmlLegendFocus($$) { const { legend } = $$.$el; legend?.selectAll(`.${$LEGEND.legendItem}`) .classed($FOCUS.legendItemFocused, false) .style("opacity", null); } /** * Apply SVG-compatible target focus state for canvas redraw. * @param {object} $$ ChartInternal instance * @param {string} id Focused data id * @private */ function setCanvasLegendTargetFocus($$, id) { const { state } = $$; const targetIds = $$.mapToTargetIds?.([id]) || [id]; const focusedIds = targetIds.filter($$.isTargetToShow, $$); const focusedSet = new Set(focusedIds); const defocusedIds = ($$.mapToTargetIds?.() || []) .filter(targetId => !focusedSet.has(targetId) && $$.isTargetToShow(targetId)); state.focusedTargetIds = focusedSet; state.defocusedTargetIds = new Set(defocusedIds); $$.renderCanvasFrame?.(undefined, null, false); } /** * Revert canvas target focus state. * @param {object} $$ ChartInternal instance * @private */ function revertCanvasLegendTargetFocus($$) { const { state } = $$; const changed = !!state.focusedTargetIds?.size || !!state.defocusedTargetIds?.size; state.focusedTargetIds = new Set(); state.defocusedTargetIds = new Set(); changed && $$.renderCanvasFrame?.(undefined, null, false); } /** * Get touch point from a canvas legend touch event. * @param {TouchEvent} event Touch event * @returns {Touch | undefined} Touch point * @private */ function getCanvasLegendTouchPoint(event) { return event.changedTouches?.[0] || event.touches?.[0]; } /** * Store the touch start position for canvas legend tap detection. * @param {object} $$ ChartInternal instance * @param {string} id Legend data id * @param {TouchEvent} event Touch event * @private */ function setCanvasLegendTouchStart($$, id, event) { const touch = getCanvasLegendTouchPoint(event); $$.state.canvasLegendTouch = touch ? { id, x: touch.clientX, y: touch.clientY, moved: false } : null; } /** * Update whether the current canvas legend touch moved beyond tap tolerance. * @param {object} $$ ChartInternal instance * @param {TouchEvent} event Touch event * @private */ function updateCanvasLegendTouchMove($$, event) { const start = $$.state.canvasLegendTouch; const touch = start && getCanvasLegendTouchPoint(event); if (touch) { start.moved = start.moved || Math.abs(touch.clientX - start.x) > CANVAS_LEGEND_TOUCH_TAP_THRESHOLD || Math.abs(touch.clientY - start.y) > CANVAS_LEGEND_TOUCH_TAP_THRESHOLD; } } /** * Determine whether a touch sequence is a canvas legend tap. * @param {object} $$ ChartInternal instance * @param {string} id Legend data id * @param {TouchEvent} event Touch event * @returns {boolean} Whether the touch sequence is a tap * @private */ function isCanvasLegendTouchTap($$, id, event) { updateCanvasLegendTouchMove($$, event); const start = $$.state.canvasLegendTouch; $$.state.canvasLegendTouch = null; return !!start && start.id === id && !start.moved; } /** * Mark a canvas legend touch tap so the following compatibility click can be skipped. * @param {object} $$ ChartInternal instance * @param {string} id Legend data id * @private */ function markCanvasLegendTouchClick($$, id) { $$.state.canvasLegendLastTouchClickId = id; $$.state.canvasLegendLastTouchClickTime = Date.now(); } /** * Check if a canvas legend click duplicates a recent touch tap. * @param {object} $$ ChartInternal instance * @param {string} id Legend data id * @returns {boolean} Whether the click is duplicate * @private */ function isDuplicateCanvasLegendTouchClick($$, id) { const { state } = $$; const duplicate = state.canvasLegendLastTouchClickId === id && Date.now() - (state.canvasLegendLastTouchClickTime || 0) < CANVAS_LEGEND_TOUCH_CLICK_TIMEOUT; if (duplicate) { state.canvasLegendLastTouchClickId = null; state.canvasLegendLastTouchClickTime = 0; } return duplicate; } /** * Bind canvas HTML legend interactions without invoking SVG focus paths. * @param {object} $$ ChartInternal instance * @param {object} item Legend item selection * @private */ function bindCanvasHtmlLegendInteractions($$, item) { const { api, config, state } = $$; item.on("click dblclick mouseover mouseout touchstart touchmove touchend", null); if (!config.interaction_enabled) { return; } const interaction = config.legend_item_interaction; const eventType = typeof interaction === "object" && interaction?.dblclick ? "dblclick" : "click"; const isTouch = state.inputType === "touch"; const hasClickInteraction = interaction || isFunction(config.legend_item_onclick); const touchOption = isTouch ? getCanvasTouchListenerOption(config) : undefined; const handleCanvasLegendToggle = function (event, id) { if (!callFn(config.legend_item_onclick, api, id, !state.hiddenTargetIds.has(id))) { const selected = select(this); if (event.type === "dblclick" || event.altKey) { if (state.hiddenTargetIds.size && !selected.classed($LEGEND.legendItemHidden)) { api.show(); } else { api.hide(); api.show(id); } } else { api.toggle(id); selected.classed($FOCUS.legendItemFocused, false); } revertCanvasLegendTargetFocus($$); } isTouch && $$.hideTooltip?.(); }; item.on(eventType, hasClickInteraction ? function (event, id) { if (isTouch && event.type === "click" && isDuplicateCanvasLegendTouchClick($$, id)) { return; } handleCanvasLegendToggle.call(this, event, id); } : null); isTouch && eventType === "click" && hasClickInteraction && item .on("touchstart", function (event, id) { setCanvasLegendTouchStart($$, id, event); }, touchOption) .on("touchmove", event => { updateCanvasLegendTouchMove($$, event); }, touchOption) .on("touchend", function (event, id) { if (isCanvasLegendTouchTap($$, id, event)) { markCanvasLegendTouchClick($$, id); handleCanvasLegendToggle.call(this, event, id); } }, touchOption); !isTouch && item .on("mouseover", interaction || isFunction(config.legend_item_onover) ? function (event, id) { if (!callFn(config.legend_item_onover, api, id, !state.hiddenTargetIds.has(id))) { setCanvasHtmlLegendFocus($$, id); !state.transiting && $$.isTargetToShow(id) && setCanvasLegendTargetFocus($$, id); } } : null) .on("mouseout", interaction || isFunction(config.legend_item_onout) ? function (event, id) { if (!callFn(config.legend_item_onout, api, id, !state.hiddenTargetIds.has(id))) { revertCanvasHtmlLegendFocus($$); revertCanvasLegendTargetFocus($$); } } : null); } /** * Measure a legend text with the same SVG structure used by the default renderer. * @param {object} $$ ChartInternal instance * @param {string} text Legend text * @returns {object|null} Text bounding rect * @private */ function measureSvgLegendText($$, text) { const chart = $$.$el.chart?.node?.(); const doc = chart?.ownerDocument; if (!chart || !doc) { return null; } const svg = doc.createElementNS("http://www.w3.org/2000/svg", "svg"); const group = doc.createElementNS("http://www.w3.org/2000/svg", "g"); const textElement = doc.createElementNS("http://www.w3.org/2000/svg", "text"); svg.style.cssText = "position:absolute;visibility:hidden;left:-10000px;top:-10000px;"; group.setAttribute("class", $LEGEND.legendItem); textElement.textContent = text; group.appendChild(textElement); svg.appendChild(group); chart.appendChild(svg); const rect = getBoundingRect(textElement); svg.remove(); return rect; } /** * Check if canvas can render the configured x axis type. * @param {string} type X axis type * @returns {boolean} Whether type is supported * @private */ function isSupportedCanvasXType(type) { return /^(indexed|category|log|timeseries)$/.test(type); } /** * Check if canvas can render the configured y/y2 axis type. * @param {string} type Y axis type * @returns {boolean} Whether type is supported * @private */ function isSupportedCanvasYType(type) { return /^(indexed|log|timeseries)$/.test(type); } /** * Get client position from mouse, pointer or touch event. * @param {Event} event Input event * @returns {object|null} Client position * @private */ function getCanvasEventClientPoint(event) { const touchEvent = event; const touch = touchEvent.changedTouches?.[0] || touchEvent.touches?.[0]; return touch || ("clientX" in event ? event : null); } /** * Get data row under a canvas pointer event. * @param {object} $$ ChartInternal instance * @param {Event} event Input event * @param {boolean} shapeOnly Whether to skip grouped x-index fallback * @returns {object|null} Hit data row * @private */ function getCanvasEventDatum($$, event, shapeOnly = false) { const point = getCanvasEventPoint($$, event); return point ? (shapeOnly ? $$.hitDetector.findNearestShape?.(point[0], point[1]) : $$.hitDetector.findNearest(point[0], point[1])) : null; } /** * Check whether a canvas pointer coordinate can select the grouped x index. * @param {object} $$ ChartInternal instance * @param {Array} point Canvas-local coordinates * @returns {boolean} Whether x-index hover is allowed * @private */ function isCanvasXIndexHoverArea($$, point) { const { config, state } = $$; const { height, margin, width, xAxisHeight } = state; const [x, y] = point; const left = margin.left; const right = margin.left + width; const top = margin.top; const bottom = margin.top + height; const axisBand = config.axis_x_show ? Math.max(xAxisHeight || 0, 24) : 0; if ($$.isMultipleX?.() || state.hasTreemap) { return false; } return config.axis_rotated ? y >= top && y <= bottom && x >= left - axisBand && x <= right : x >= left && x <= right && y >= top && y <= bottom + axisBand; } /** * Check whether a canvas pointer coordinate is inside the main plot area. * @param {object} $$ ChartInternal instance * @param {Array} point Canvas-local coordinates * @returns {boolean} Whether axis tooltip can be shown * @private */ function isCanvasAxisTooltipArea($$, point) { const { state: { height, margin, width } } = $$; const [x, y] = point; return x >= margin.left && x <= margin.left + width && y >= margin.top && y <= margin.top + height; } /** * Get data row for hover from a canvas-local coordinate. * @param {object} $$ ChartInternal instance * @param {Array} point Canvas-local coordinates * @returns {object|null} Hover data row * @private */ function getCanvasHoverDatumFromPoint($$, point) { return $$.hitDetector.findNearest(point[0], point[1]) || (isCanvasXIndexHoverArea($$, point) ? $$.hitDetector.findNearestIndexByCoord?.(point[0], point[1]) : null); } /** * Get visible canvas data rows for a tooltip target. * @param {object} $$ ChartInternal instance * @param {object} d Base data row * @returns {Array} Tooltip data rows * @private */ function getCanvasTooltipData($$, d) { const { config, state } = $$; const targetsToShow = $$.filterTargetsToShow($$.data.targets); const pointBased = isCanvasPointBasedInteraction($$, d); const sameXData = config.tooltip_grouped && !state.hasTreemap && !pointBased ? $$.filterByX?.(targetsToShow, d.x) : null; return (state.hasTreemap ? [d] : (pointBased ? [d] : (config.tooltip_grouped ? (sameXData?.length ? sameXData : targetsToShow.map(target => target.values[d.index]).filter(Boolean)) : [d]))) .map(v => $$.addName?.(v) || v); } /** * Get data rows for canvas focus rendering. * @param {object} $$ ChartInternal instance * @param {object} d Hover data row * @param {Array} selectedData Tooltip data rows * @returns {Array} Focus data rows * @private */ function getCanvasFocusData($$, d, selectedData) { return $$.isMultipleX?.() && !isCanvasPointBasedInteraction($$, d) ? [$$.addName?.(d) || d] : selectedData; } /** * Get canvas-local coordinates from mouse, pointer or touch event. * @param {object} $$ ChartInternal instance * @param {Event} event Input event * @returns {Array|null} Canvas-local coordinates * @private */ function getCanvasEventPoint($$, event) { const point = getCanvasEventClientPoint(event); if (!point) { return null; } const canvas = $$.$el.canvas.node(); const rect = getBoundingRect(canvas, true); return [point.clientX - rect.left, point.clientY - rect.top]; } /** * Normalize a subchart domain range for the current x axis type. * @param {object} $$ ChartInternal instance * @param {Array} domain Domain range * @returns {Array} Normalized domain range * @private */ function normalizeCanvasSubchartDomain($$, domain) { if (!Array.isArray(domain) || domain.length < 2) { return null; } const values = domain.slice(0, 2); return $$.axis?.isTimeSeries?.() ? values.map(value => parseDate.call($$, value)) : values; } /** * Get the canvas-space rectangle occupied by the subchart plot. * @param {object} $$ ChartInternal instance * @returns {object|null} Subchart rectangle * @private */ function getCanvasSubchartRect($$) { const { config, state } = $$; if (!config.subchart_show || !state.hasAxis || state.width2 <= 0 || state.height2 <= 0) { return null; } return { x: state.margin2.left, y: state.margin2.top, w: state.width2, h: state.height2 }; } /** * Check whether a canvas point is inside the subchart plot area. * @param {object} $$ ChartInternal instance * @param {Array} point Canvas-local point * @returns {boolean} Whether point is in the subchart * @private */ function isCanvasSubchartPoint($$, point) { const rect = getCanvasSubchartRect($$); return !!rect && point[0] >= rect.x && point[0] <= rect.x + rect.w && point[1] >= rect.y && point[1] <= rect.y + rect.h; } /** * Get the allowed local brush coordinate extent for canvas subchart interactions. * @param {object} $$ ChartInternal instance * @returns {Array} Brush extent in subchart-local pixels * @private */ function getCanvasSubchartBrushExtent($$) { const rect = getCanvasSubchartRect($$); const axisLength = rect ? ($$.config.axis_rotated ? rect.h : rect.w) : 0; const extent = $$.axis?.getExtent?.(); const values = Array.isArray(extent) ? extent.slice(0, 2) : []; if (values.length === 2 && values.every(Number.isFinite)) { const start = Math.max(0, Math.min(axisLength, values[0])); const end = Math.max(0, Math.min(axisLength, values[1])); return [Math.min(start, end), Math.max(start, end)]; } return [0, axisLength]; } /** * Get the local brush coordinate from a canvas subchart pointer event. * @param {object} $$ ChartInternal instance * @param {Event} event Input event * @param {boolean} clampToExtent Whether to clamp pointer outside brush extent * @returns {number|null} Brush coordinate * @private */ function getCanvasSubchartBrushCoord($$, event, clampToExtent = false) { const point = getCanvasEventPoint($$, event); const rect = point && getCanvasSubchartRect($$); if (!point || !rect || !isCanvasSubchartPoint($$, point)) { return null; } const raw = $$.config.axis_rotated ? point[1] - rect.y : point[0] - rect.x; const [min, max] = getCanvasSubchartBrushExtent($$); if (!clampToExtent && (raw < min || raw > max)) { return null; } return Math.max(min, Math.min(max, raw)); } /** * Build a scale domain from two subchart brush coordinates. * @param {object} $$ ChartInternal instance * @param {number} start Brush start coordinate * @param {number} end Brush end coordinate * @returns {Array} Domain range * @private */ function getCanvasSubchartDomainFromCoords($$, start, end) { const coordStart = Math.min(start, end); const coordEnd = Math.max(start, end); return [ $$.scale.subX.invert(coordStart), $$.scale.subX.invert(coordEnd) ]; } /** * Get current canvas subchart brush selection in local subchart pixels. * @param {object} $$ ChartInternal instance * @returns {Array|null} Brush selection pixels * @private */ function getCanvasSubchartBrushSelection($$) { const { scale, state } = $$; const domain = state.domain; if (!domain?.length || !scale.subX) { return null; } const rect = getCanvasSubchartRect($$); const p0 = scale.subX(domain[0]); const p1 = scale.subX(domain[1]); if (!rect || !Number.isFinite(p0) || !Number.isFinite(p1)) { return null; } const [min, max] = getCanvasSubchartBrushExtent($$); return [ Math.max(min, Math.min(max, Math.min(p0, p1))), Math.max(min, Math.min(max, Math.max(p0, p1))) ]; } /** * Get the subchart brush mode at a local brush coordinate. * @param {object} $$ ChartInternal instance * @param {number} coord Brush coordinate * @returns {string} Brush mode * @private */ function getCanvasSubchartBrushMode($$, coord) { const selection = getCanvasSubchartBrushSelection($$); if (!selection) { return "select"; } const [start, end] = selection; if (Math.abs(coord - start) <= CANVAS_SUBCHART_HANDLE_SIZE) { return "resize-start"; } if (Math.abs(coord - end) <= CANVAS_SUBCHART_HANDLE_SIZE) { return "resize-end"; } return coord > start && coord < end ? "move" : "select"; } /** * Clamp a subchart brush selection to the subchart extent. * @param {object} $$ ChartInternal instance * @param {number} start Selection start pixel * @param {number} end Selection end pixel * @returns {Array} Clamped selection pixels * @private */ function clampCanvasSubchartSelection($$, start, end) { const [min, max] = getCanvasSubchartBrushExtent($$); return [ Math.max(min, Math.min(max, start)), Math.max(min, Math.min(max, end)) ]; } /** * Get cursor for a canvas subchart brush mode. * @param {object} $$ ChartInternal instance * @param {string} mode Brush mode * @returns {string} CSS cursor * @private */ function getCanvasSubchartCursor($$, mode) { if (/^resize/.test(mode)) { return $$.config.axis_rotated ? "ns-resize" : "ew-resize"; } return mode === "move" ? "move" : "crosshair"; } /** * Get stable key for canvas data callbacks. * @param {object} d Data row * @returns {string|null} Data key * @private */ function getCanvasDataKey(d) { return d ? `${d.id}:${d.index}` : null; } /** * Get canvas drag selection delta rows from current and previous hit rows. * @param {object} $$ ChartInternal instance * @param {Array} dataRows Current drag hit rows * @returns {object} Current included keys and rows to toggle * @private */ function getCanvasSelectionDragDelta($$, dataRows) { const previousKeys = $$.state.canvasSelectionDragIncluded; const previousRows = canvasSelectionDragRows.get($$) || new Map(); const currentRows = new Map(); const included = new Set(); const rows = []; dataRows .filter(d => isCanvasSelectableData($$, d)) .forEach(d => { const key = getCanvasDataKey(d); if (key) { included.add(key); !currentRows.has(key) && currentRows.set(key, d); } }); currentRows.forEach((d, key) => { !previousKeys.has(key) && rows.push(d); }); previousRows.forEach((d, key) => { !included.has(key) && rows.push(d); }); canvasSelectionDragRows.set($$, currentRows); return { included, rows }; } /** * Normalize a data id or id list. * @param {string|Array} ids Data ids * @returns {Array|null} Normalized ids * @private */ function getCanvasSelectionIds(ids) { return ids ? (Array.isArray(ids) ? ids : [ids]) : null; } /** * Check whether a canvas data row participates in selection. * @param {object} $$ ChartInternal instance * @param {object} d Data row * @returns {boolean} Whether the row can be selected * @private */ function isCanvasSelectableData($$, d) { return d && hasCanvasDrawableValue($$, d) && isCanvasTargetSupported($$, d, CANVAS_SELECTABLE_TYPE_FILTERS) && CANVAS_SELECTABLE_TYPE_FILTERS.some(filter => filter($$, d)) && $$.config.data_selection_isselectable.bind($$.api)(d); } /** * Iterate selectable canvas rows. * @param {object} $$ ChartInternal instance * @param {function} callback Row callback * @private */ function eachCanvasSelectableData($$, callback) { $$.filterTargetsToShow($$.data.targets) .filter(target => isCanvasTargetSupported($$, target, CANVAS_SELECTABLE_TYPE_FILTERS)) .forEach(target => { target.values.forEach(d => { isCanvasSelectableData($$, d) && callback(d); }); }); } /** * Check whether a canvas data row should interact as a single point. * @param {object} $$ ChartInternal instance * @param {object} d Data row * @returns {boolean} Whether the row should stay ungrouped * @private */ function isCanvasPointBasedInteraction($$, d) { return !($$.config.axis_x_forceAsSingle && $$.config.tooltip_grouped) && (isCanvasScatterType($$, d) || isCanvasBubbleType($$, d)); } /** * Check if touch input is enabled by option. * @param {object} config Config object * @returns {boolean} Whether touch is enabled * @private */ function isCanvasTouchEnabled(config) { return config.interaction_inputType_touch !== false; } /** * Get touch listener passive option following interaction.inputType.touch.preventDefault. * @param {object} config Config object * @returns {object} Event listener options * @private */ function getCanvasTouchListenerOption(config) { const preventDefault = config.interaction_inputType_touch?.preventDefault; const isPrevented = (isBoolean(preventDefault) && preventDefault) || false; const preventThreshold = (!isNaN(preventDefault) && preventDefault) || null; return { passive: !isPrevented && preventThreshold === null }; } /** * Create touch preventDefault handler following interaction.inputType.touch.preventDefault. * @param {object} config Config object * @returns {function} Touch preventer * @private */ function getCanvasTouchPreventer(config) { const preventDefault = config.interaction_inputType_touch?.preventDefault; const isPrevented = (isBoolean(preventDefault) && preventDefault) || false; const preventThreshold = (!isNaN(preventDefault) && preventDefault) || null; let startPx; return event => { const touch = event.changedTouches?.[0] || event.touches?.[0]; if (!touch) { return; } const currentXY = touch[`client${config.axis_rotated ? "Y" : "X"}`]; if (event.type === "touchstart") { if (isPrevented) { event.preventDefault(); } else if (preventThreshold !== null) { startPx = currentXY; } } else if (event.type === "touchmove" && (isPrevented || startPx === true || (preventThreshold !== null && Math.abs(startPx - currentXY) >= preventThreshold))) { startPx = true; event.preventDefault(); } }; } /** * Check whether a pointer event should be handled by canvas pointer path. * @param {object} $$ ChartInternal instance * @param {PointerEvent} event Pointer event * @returns {boolean} Whether event should be handled * @private */ function shouldHandleCanvasPointerEvent($$, event) { return event.pointerType !== "mouse" && $$.state.inputType !== "touch"; } /** * Check if click event duplicates a recent touch/pointer data click. * @param {object} $$ ChartInternal instance * @param {object} d Data row * @returns {boolean} Whether the click should skip data callback * @private */ function isDuplicateCanvasInputClick($$, d) { const { state } = $$; const key = getCanvasDataKey(d); const duplicate = key && state.canvasLastInputClickKey === key && Date.now() - (state.canvasLastInputClickTime || 0) < 750; if (duplicate) { state.canvasLastInputClickKey = null; state.canvasLastInputClickTime = 0; } return Boolean(duplicate); } /** * Mark a touch/pointer data click so the following compatibility click can be ignored. * @param {object} $$ ChartInternal instance * @param {object} d Data row * @private */ function markCanvasInputClick($$, d) { $$.state.canvasLastInputClickKey = getCanvasDataKey(d); $$.state.canvasLastInputClickTime = Date.now(); } /** * Get current animation timestamp. * @returns {number} Timestamp * @private */ function getCanvasAnimationTime() { return win.performance?.now?.() ?? Date.now(); } /** * Check whether a domain can be interpolated for canvas flow. * @param {Array} domain Domain values * @param {boolean} isLog Whether the domain is in log scale * @returns {boolean} Whether domain is numeric/date-like * @private */ function isCanvasFlowDomain(domain, isLog = false) { return Array.isArray(domain) && domain.length >= 2 && domain.every(v => Number.isFinite(+v) && (!isLog || +v > 0)); } /** * Interpolate numeric/date domain values. * @param {Array} start Start domain * @param {Array} end End domain * @param {number} ratio Interpolation ratio * @param {boolean} isLog Whether to interpolate in log space * @returns {Array} Interpolated domain * @private */ function interpolateCanvasFlowDomain(start, end, ratio, isLog = false) { return start.slice(0, 2).map((value, index) => { const next = isLog ? Math.exp(Math.log(+value) + ((Math.log(+end[index]) - Math.log(+value)) * ratio)) : +value + ((+end[index] - +value) * ratio); return value instanceof Date || end[index] instanceof Date ? new Date(next) : next; }); } /** * Count current canvas flow values. * @param {object} $$ ChartInternal instance * @returns {number} Total values * @private */ function getCanvasFlowValueCount($$) { return $$.data.targets.reduce((sum, target) => sum + target.values.length, 0); } /** * Sync y/y2 domains for canvas flow frames after data has been appended. * @param {object} $$ ChartInternal instance * @private */ function syncCanvasFlowYDomains($$) { const { scale } = $$; const targetsToShow = $$.filterTargetsToShow($$.data.targets); ["y", "y2"].forEach(key => { scale[key]?.domain($$.getYDomain(targetsToShow, key)); }); scale.subY?.domain($$.getYDomain(targetsToShow, "y")); scale.subY2?.domain($$.getYDomain(targetsToShow, "y2")); } const canvasInternal = { /** * Normalize unsupported options before initializing canvas mode. * @private */ prepareCanvasConfig() { const { config, state } = this; this.warnUnsupportedCanvasOptions(); if (state.orgConfig?.transition && Object.prototype.hasOwnProperty.call(state.orgConfig.transition, "duration") && state.orgConfig.transition.duration) { warn("canvas mode: transition.duration is ignored; canvas redraws synchronously."); } config.transition_duration = 0; if (!isSupportedCanvasXType(config.axis_x_type)) { config.axis_x_type = "indexed"; } if (!isSupportedCanvasYType(config.axis_y_type)) { config.axis_y_type = "indexed"; } if (!isSupportedCanvasYType(config.axis_y2_type)) { config.axis_y2_type = "indexed"; } if (this.hasType?.("bubble")) { config.point_show = true; config.point_type = "circle"; } }, /** * Apply the current canvas subchart selection to the main x domain. * @private */ applyCanvasSubchartDomain() { const $$ = this; const { axis, config, scale, state } = $$; if (!config.subchart_show || !state.hasAxis || !scale.subX) { return; } if (!state.rendered && !state.domain && config.subchart_init_range) { state.domain = normalizeCanvasSubchartDomain($$, config.subchart_init_range); } const domain = normalizeCanvasSubchartDomain($$, state.domain); if (!domain || !$$.withinRange(domain, $$.getZoomDomain("subX", true), $$.getZoomDomain("subX"))) { return; } state.domain = domain; scale.x.domain(domain); axis.x.scale(scale.x); }, /** * Set the canvas subchart selection domain and redraw the main chart. * @param {Array} domain Domain range * @param {boolean} redraw Whether to redraw immediately * @param {boolean} callCallback Whether to call subchart.onbrush * @returns {Array|undefined} Applied domain range * @private */ setCanvasSubchartDomain(domain, redraw = true, callCallback = false) { const $$ = this; const { config, scale, state } = $$; const nextDomain = normalizeCanvasSubchartDomain($$, domain); if (!config.subchart_show || !nextDomain || !$$.withinRange(nextDomain, $$.getZoomDomain("subX", true), $$.getZoomDomain("subX"))) { return undefined; } state.domain = nextDomain; scale.zoom = null; if (redraw) { $$.redraw({ withTransition: false, withY: config.zoom_rescale, withSubchart: false, withEventRect: false, withDimension: false }); } callCallback && config.subchart_onbrush.bind($$.api)(state.domain); return state.domain; }, /** * Clear the canvas subchart brush selection and restore the full x domain. * @param {boolean} redraw Whether to redraw immediately * @param {boolean} callCallback Whether to call subchart.onbrush * @private */ clearCanvasSubchartDomain(redraw = true, callCallback = false) { const $$ = this; const { axis, config, org, scale, state } = $$; if (!config.subchart_show) { return; } state.domain = undefined; scale.zoom = null; if (org.xDomain?.length) { scale.x.domain(org.xDomain); axis.x.scale(scale.x); } if (redraw) { $$.redraw({ withTransition: false, withUpdateXDomain: true, withUpdateOrgXDomain: false, withY: config.zoom_rescale, withSubchart: false, withEventRect: false, withDimension: false }); } callCallback && config.subchart_onbrush.bind($$.api)(scale.x.orgDomain()); }, /** * Finish a pending canvas flow animation before accepting another flow mutation. * @private */ flushCanvasFlow() { const { state } = this; if (!state.canvasFlowFinish) { return; } state.canvasFlowFrame !== null && win.cancelAnimationFrame?.(state.canvasFlowFrame); state.canvasFlowFrame = null; state.canvasFlowFinish(); }, /** * Animate canvas flow by interpolating the x domain, then commit the final data window. * @param {object} flow Flow metadata * @returns {boolean} Whether animation was started * @private */ animateCanvasFlow(flow) { const $$ = this; const { axis, data, org, scale, state } = $$; const { done = () => { }, duration, length, orgDataCount } = flow; const requestFrame = win.requestAnimationFrame?.bind(win); const isLog = !!axis.isLog?.("x"); if (!requestFrame || !duration || !length || !orgDataCount || getCanvasFlowValueCount($$) > CANVAS_FLOW_ANIMATION_MAX_VALUES) { return false; } const startDomain = scale.x.domain().slice(0, 2); const startOrgDomain = org.xDomain?.slice?.(); const startSubXDomain = scale.subX?.domain?.().slice?.(); if (!isCanvasFlowDomain(startDomain, isLog)) { return false; } const removed = data.targets.map(target => target.values.splice(0, length)); $$.updateXDomain($$.filterTargetsToShow(data.targets), true, true); const endDomain = scale.x.domain().slice(0, 2); const endOrgDomain = org.xDomain?.slice?.(); data.targets.forEach((target, index) => { target.values.unshift(...removed[index]); }); if (!isCanvasFlowDomain(endDomain, isLog)) { startOrgDomain && (org.xDomain = startOrgDomain); scale.x.domain(startDomain); startSubXDomain && scale.subX.domain(startSubXDomain); axis.x.scale(scale.x); return false; } syncCanvasFlowYDomains($$); let finished = false; const started = getCanvasAnimationTime(); const finish = () => { if (finished) { return; } finished = true; data.targets.forEach(target => { target.values.splice(0, length); }); endOrgDomain && (org.xDomain = endOrgDomain); scale.x.domain(endDomain); scale.subX?.domain?.(endOrgDomain ?? endDomain); axis.x.scale(scale.x); state.dirty.data = true; state._eventRectFingerprint = null; state.canvasShape = null; state._cachedDrawShape = null; $$.redraw({ withLegend: true, withTransition: false, withTrimXDomain: false, withUpdateOrgXDomain: true, withUpdateXAxis: true, withUpdateXDomain: true }); $$.updateTypesElements(); state.canvasFlowFrame = null; state.canvasFlowFinish = null; state.flowing = false; done.call($$.api); }; const render = (timestamp) => { const ratio = Math.min(1, Math.max(0, (timestamp - started) / duration)); const domain = interpolateCanvasFlowDomain(startDomain, endDomain, ratio, isLog); scale.x.domain(domain); axis.x.scale(scale.x); state.canvasShape = null; state._cachedDrawShape = null; state._canvasVisibleRangeCache = null; state._canvasXTickValuesCache = null; $$.renderCanvasFrame(undefined, null, false); if (ratio < 1) { state.canvasFlowFrame = requestFrame(render); } else { finish(); } }; startOrgDomain && (org.xDomain = startOrgDomain); scale.x.domain(startDomain); startSubXDomain && scale.subX.domain(startSubXDomain); axis.x.scale(scale.x); state.flowing = true; state.cancelClick = true; $$.hideTooltip?.(); state.canvasFlowFinish = finish; state.canvasFlowFrame = requestFrame(render); return true; }, /** * Initialize canvas renderers, theme, hit detector and event bindings. * @private */ initCanvas() { const $$ = this; const { config, state, $el } = $$; const container = $el.chart.node(); state.isCanvasMode = true; state.canvasInlineStyle.minHeight = container.style.minHeight; if (win.getComputedStyle(container).position === "static") { $el.chart.style("position", "relative"); } $el.chart.style("min-height", `${state.current.height}px`); $$.canvasEngine = new CanvasEngine(); $$.canvasEngine.init(container, state.current.width, $$.getCanvasSurfaceHeight()); $el.canvas = select($$.canvasEngine.canvas); $$.canvasTheme = new CanvasTheme(); $$.canvasTheme.load(container, config.canvas_theme); $$.canvasAxisRenderer = new CanvasAxisRenderer($$.canvasEngine, $$.canvasTheme); $$.canvasRenderer = new CanvasRenderer($$.canvasEngine, $$.canvasTheme); $$.hitDetector = new HitDetector(); $$.bindCanvasEvents(); config.zoom_enabled && $$.bindZoomEvent?.(); }, /** * Warn about options that canvas mode does not support yet. * @private */ warnUnsupportedCanvasOptions() { const { config } = this; const unsupported = [ [ !isSupportedCanvasXType(config.axis_x_type), "axis.x.type other than indexed/category/log/timeseries" ], [ !isSupportedCanvasYType(config.axis_y_type), "axis.y.type other than indexed/log/timeseries" ], [ !isSupportedCanvasYType(config.axis_y2_type), "axis.y2.type other than indexed/log/timeseries" ], [config.boost_useCssRule, "boost.useCssRule"], [this.hasArcType?.(), "arc charts"], [this.hasType?.("radar"), "radar chart"], [this.hasType?.("polar"), "polar chart"], [this.hasType?.("funnel"), "funnel chart"] ]; unsupported.forEach(([condition, name]) => { condition && warn(`canvas mode: ${name} is not yet supported.`); }); }, /** * Bind pointer and click handlers to the canvas surface. * @private */ bindCanvasEvents() { const $$ = this; const { config, state, $el } = $$; const canvas = $el.canvas.node(); const preventTouchEvent = getCanvasTouchPreventer(config); const touchOption = getCanvasTouchListenerOption(config); canvas.addEventListener("mousemove", $$.onCanvasMouseMove.bind($$)); canvas.addEventListener("mouseenter", event => { state.event = event; config.onover?.bind($$.api)(event); }); canvas.addEventListener("mouseout", $$.onCanvasMouseOut.bind($$)); canvas.addEventListener("mouseleave", event => { state.event = event; config.onout?.bind($$.api)(event); }); canvas.addEventListener("mousedown", event => { if ($$.onCanvasSubchartBrushStart?.(event)) { return; } $$.onCanvasSelectionDragStart(event); }); canvas.addEventListener("click", $$.onCanvasClick.bind($$)); canvas.addEventListener("pointerdown", $$.onCanvasPointerDown.bind($$)); canvas.addEventListener("pointerenter", $$.onCanvasPointerEnter.bind($$)); canvas.addEventListener("pointermove", $$.onCanvasPointerMove.bind($$)); canvas.addEventListener("pointerup", $$.onCanvasPointerUp.bind($$)); canvas.addEventListener("pointerleave", $$.onCanvasPointerOut.bind($$)); canvas.addEventListener("pointercancel", $$.onCanvasPointerCancel.bind($$)); if (isCanvasTouchEnabled(config)) { canvas.addEventListener("touchstart", event => { preventTouchEvent(event); $$.onCanvasTouchStart(event); }, touchOption); canvas.addEventListener("touchmove", event => { preventTouchEvent(event); $$.onCanvasTouchMove(event); }, touchOption); canvas.addEventListener("touchend", $$.onCanvasTouchEnd.bind($$), touchOption); canvas.addEventListener("touchcancel", $$.onCanvasTouchCancel.bind($$), touchOption); } }, /** * Render and update the HTML legend used by canvas mode. * @private */ updateHtmlLegend() { const $$ = this; const { config, state, $el } = $$; const chart = $el.chart.node(); if (!config.legend_show || state.hasTreemap) { if (state.hasTreemap) { $el.legend?.remove(); $el.legend = null; } else { $el.legend?.style("visibility", "hidden"); } state.legendItemWidth = 0; state.legendItemHeight = 0; state.legendStep = 0; return; } const targetIds = $$.mapToIds($$.data.targets) .filter(id => config.data_names[id] !== null); if (config.legend_contents_bindto && config.legend_contents_template) { if ($$.updateHtmlLegendTemplate(targetIds)) { state.legendItemWidth = 0; state.legendItemHeight = 0; state.legendStep = 0; state.legendHasRendered = true; } else { state.legendItemWidth = 0; state.legendItemHeight = 0; state.legendStep = 0; } return; } if (!$el.legend) { $el.legend = select(chart) .append("div") .classed($LEGEND.legend, true) .classed($CANVAS.legend, true); } $el.legend.style("visibility", null); const legendItems = $el.legend .selectAll(`button.${$LEGEND.legendItem}`) .data(targetIds, (id) => id); legendItems.exit().remove(); const enter = legendItems.enter() .append("button") .attr("type", "button") .attr("data-id", id => id); enter.append("span") .classed($LEGEND.legendItemTile, true); enter.append("span") .classed($CANVAS.legendItemTitle, true); const item = enter.merge(legendItems) .attr("class", id => $$.generateClass($LEGEND.legendItem, id).trim()); setCanvasHtmlLegendItem($$, item); item.select(`.${$LEGEND.legendItemTile}`) .classed($CANVAS.legendItemTileCircle, !config.legend_usePoint && config.legend_item_tile_type === "circle") .style("background-color", id => (config.legend_usePoint ? null : $$.color(id))) .html(id => (config.legend_usePoint ? getCanvasLegendPointIcon($$, id) : "")); item.select(`.${$CANVAS.legendItemTitle}`) .text(id => getLegendText($$, id)); if (config.legend_tooltip) { item.attr("title", id => getLegendText($$, id)); } bindCanvasHtmlLegendInteractions($$, item); $$.updateHtmlLegendSize(targetIds); $$.positionHtmlLegend(); state.legendHasRendered = true; }, /** * Render canvas mode legend into c